Compare commits

..

3 Commits

Author SHA1 Message Date
usrnk1 cb7415dcc7 feat(desktop): add home session actions 2026-08-18 16:12:11 +02:00
Dax Raad 643eed300d fix(cli): log managed service lease loss 2026-08-18 10:06:59 -04:00
Dax Raad c3a6721de2 refactor(tui): standardize builtin plugin ids 2026-08-18 10:06:59 -04:00
18 changed files with 524 additions and 103 deletions
@@ -0,0 +1,108 @@
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()
})
@@ -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 { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
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 "@/components/titlebar-session-events"
import { useCommand } from "@/context/command"
import {
loadHomeSessionIndex,
@@ -15,7 +19,10 @@ import { ServerConnection } from "@/context/servers"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { removedSessionIDs } from "@/pages/session/session-domain"
import { pathKey } from "@/utils/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { sessionLabel, sessionTitle } from "@/utils/session-title"
import { showToast } from "@/utils/toast"
import { archiveHomeSession } from "../home-session-archive"
import type { HomeController } from "./home-controller"
@@ -40,6 +47,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 project = home.project.selected()
if (!project) return home.project.list().flatMap(directories)
@@ -65,9 +74,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(),
)
@@ -130,6 +143,112 @@ 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 })
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
})
}
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 (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
}
return {
copy: {
language,
@@ -190,6 +309,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) =>
@@ -1,10 +1,13 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo, For, Show, Suspense } from "solid-js"
import { createMemo, For, onCleanup, 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/servers"
@@ -22,6 +25,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 +58,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
@@ -415,42 +422,238 @@ function HomeSessionGroupHeader(props: {
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName
const [state, setState] = createStore({
draft: "",
editing: false,
menuX: 0,
menuY: 0,
menuOpen: false,
pendingAction: undefined as "rename" | "export" | "delete" | undefined,
renaming: false,
})
let titleRef: HTMLInputElement | undefined
let rowRef: HTMLButtonElement | undefined
let longPressTimer: ReturnType<typeof setTimeout> | undefined
let longPressStart: { x: number; y: number } | undefined
let suppressClick = false
const clearLongPress = () => {
if (longPressTimer !== undefined) clearTimeout(longPressTimer)
longPressTimer = undefined
longPressStart = undefined
}
const finishLongPress = () => {
clearLongPress()
if (!suppressClick) return
setTimeout(() => {
suppressClick = false
})
}
onCleanup(clearLongPress)
const openMenu = (element: HTMLElement, clientX: number, clientY: number) => {
const bounds = element.getBoundingClientRect()
setState({ menuX: clientX - bounds.left, menuY: clientY - bounds.top, menuOpen: true })
}
const openEditor = () => {
setState({ draft: title(), editing: true })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeEditor = () => {
if (state.renaming) return
setState("editing", false)
}
const saveEditor = async () => {
if (state.renaming) return
setState("renaming", true)
const saved = await props.onRenameSession(props.server, props.record.session, state.draft)
setState("renaming", false)
if (saved) setState("editing", false)
}
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) => {
event.preventDefault()
if (state.editing) return
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={!state.editing}
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
ref={(element) => {
titleRef = element
}}
data-component="home-session-rename"
dir="auto"
value={state.draft}
disabled={state.renaming}
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) => setState("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
<button
ref={(element) => {
rowRef = element
}}
type="button"
data-component="home-session-row"
aria-haspopup="menu"
aria-expanded={state.menuOpen}
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) => {
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={finishLongPress}
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) => {
if (suppressClick) {
suppressClick = false
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>
<MenuV2
modal={false}
placement="bottom-start"
gutter={2}
open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)}
>
<MenuV2.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${state.menuX}px`, top: `${state.menuY}px` }}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
<MenuV2.Portal>
<MenuV2.Content
onCloseAutoFocus={(event) => {
event.preventDefault()
const action = state.pendingAction
if (!action) {
requestAnimationFrame(() => rowRef?.focus())
return
}
setState("pendingAction", undefined)
if (action === "rename") {
openEditor()
return
}
requestAnimationFrame(() => {
if (action === "export") {
void props.onExportSession(props.server, props.record.session)
return
}
props.onDeleteSession(props.server, props.record.session)
})
}}
>
<MenuV2.Item onSelect={() => setState({ pendingAction: "rename", menuOpen: false })}>
{props.language.t("common.rename")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => setState({ pendingAction: "export", menuOpen: false })}>
{props.language.t("common.export")}...
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => setState({ pendingAction: "delete", menuOpen: false })}>
{props.language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div
class={`
@@ -481,6 +684,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,
@@ -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}
@@ -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"])
})
})
@@ -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
}
@@ -22,19 +22,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
}
@@ -12,12 +12,13 @@ import { useSettings } from "@/context/settings"
import { useWorkspaceLocation } from "@/context/location"
import { useTabs } from "@/context/tabs"
import type { SessionController } from "@/pages/session/session-controller"
import { removedSessionIDs } from "@/pages/session/session-domain"
import { useServerSDK } from "@/context/server-sdk"
import { sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection"
import { timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/context/server"
@@ -171,7 +172,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
+7
View File
@@ -194,6 +194,13 @@ const register = Effect.fnUntraced(function* (
yield* current.pipe(
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.tapError(() =>
Effect.logWarning("managed service registration lost; shutting down", {
serviceID: id,
servicePID: process.pid,
registration: file,
}),
),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
@@ -99,7 +99,7 @@ function View(props: { context: Plugin.Context }) {
}
export default Plugin.define({
id: "opencode.home-footer",
id: "opencode.home.footer",
setup(context) {
// Root takeover: an external plugin replacing home.footer wins (last-
// enabled) and this builtin shows as suppressed, not silently gone.
@@ -83,7 +83,7 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
}
export default Plugin.define({
id: "opencode.prompt-footer",
id: "opencode.prompt.footer",
setup(context) {
context.ui.slot({
append: "prompt.footer",
@@ -42,7 +42,7 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
}
export default Plugin.define({
id: "internal:sidebar-context",
id: "opencode.sidebar.context",
setup(context) {
context.ui.slot({
append: "sidebar.content",
@@ -40,7 +40,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
}
export default Plugin.define({
id: "opencode.sidebar-footer",
id: "opencode.sidebar.footer",
setup(context) {
// Append keeps the path open to additive plugin claims; an external
// replace still takes the boundary over.
@@ -71,7 +71,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
}
export default Plugin.define({
id: "internal:sidebar-mcp",
id: "opencode.sidebar.mcp",
setup(context) {
context.ui.slot({
append: "sidebar.content",
@@ -1079,7 +1079,7 @@ function Commands(props: { context: Plugin.Context }) {
}
export default Plugin.define({
id: "diff-viewer",
id: "opencode.diffs",
setup(context) {
context.ui.router.register({
name: ROUTE,
-12
View File
@@ -523,18 +523,6 @@ export function FormPrompt(props: {
textarea?.setText("")
},
},
{
id: "prompt.paste",
title: "Paste into answer",
group: "Form",
async run(_input, event) {
event?.preventDefault()
event?.stopPropagation()
const content = await clipboard.read()
if (content?.mime !== "text/plain") return
textarea?.insertText(content.data)
},
},
{
bind: "escape",
title: textual() ? "Dismiss form" : "Close answer edit",
@@ -29,7 +29,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
try {
expect(viewer.current()).toEqual({
type: "plugin",
id: "diff-viewer",
id: "opencode.diffs",
name: "diff",
data: { mode: "working", sessionID: "session-1", returnRoute: startRoute },
})
@@ -207,7 +207,7 @@ async function renderDiffViewer(
navigate(destination: Destination) {
setCurrent(
destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "diff-viewer" }
? { ...destination, id: "opencode.diffs" }
: destination,
)
},
@@ -334,7 +334,7 @@ test("branch diff source requests branch VCS diff", async () => {
const viewer = await renderDiffViewer([], {
initialRoute: {
type: "plugin",
id: "diff-viewer",
id: "opencode.diffs",
name: "diff",
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
},
@@ -342,7 +342,7 @@ test("branch diff source requests branch VCS diff", async () => {
try {
expect(viewer.current()).toEqual({
type: "plugin",
id: "diff-viewer",
id: "opencode.diffs",
name: "diff",
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
})
+2 -30
View File
@@ -14,7 +14,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20, pasted?: string) {
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -58,7 +58,7 @@ async function mountForm(root: string, width = 80, fields?: FormWithLocation["fi
}}
clipboard={{
async read() {
return pasted ? { data: pasted, mime: "text/plain" } : undefined
return undefined
},
write(text) {
copied.push(text)
@@ -217,34 +217,6 @@ test("pasting on a custom choice opens its editor without submitting", async ()
}
})
test("ctrl-v pastes clipboard text into a custom answer", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(
tmp.path,
80,
[
{
key: "target",
type: "string",
options: [{ value: "staging", label: "Staging" }],
custom: true,
},
],
20,
"production west",
)
try {
prompt.app.mockInput.pressArrow("down")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
prompt.app.mockInput.pressKey("v", { ctrl: true })
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
} finally {
prompt.app.renderer.destroy()
}
})
test("typing a custom multiselect answer selects it before commit", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, [