Compare commits

...
11 changed files with 630 additions and 35 deletions
@@ -0,0 +1,162 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/OpenCode/RecentSessions"
const longTitle = "A deliberately long recent session title that must truncate inside the compact titlebar popover"
const sessions = [
session("ses_search_target", "Hidden search target", 1),
session("ses_2", "Recent session 2", 2),
session("ses_3", "Recent session 3", 3),
session("ses_4", "Recent session 4", 4),
session("ses_5", "Recent session 5", 5),
session("ses_6", "Recent session 6", 6),
session("ses_7", longTitle, 7),
]
test("opens and searches recent sessions from the new-session button", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_recent_sessions",
worktree: directory,
vcs: "git",
name: "RecentSessions",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: { all: [], connected: [], default: null },
sessions: [...sessions],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
recentlyClosed: { local: [] },
}),
)
localStorage.setItem(
"opencode.global.dat:notification",
JSON.stringify({
list: [
{
type: "turn-complete",
time: Date.now(),
viewed: false,
session: "ses_7",
directory,
},
],
}),
)
}, directory)
await page.goto("/")
const tabNav = page.locator('[data-slot="titlebar-tab-nav"]')
const blankTabNav = page.locator('[data-slot="titlebar-tab-nav-blank"]')
const newSession = page.locator('[data-action="titlebar-new-session"]')
await expect(newSession).toBeVisible()
const triggerRect = () =>
newSession.evaluate((element) => {
const rect = element.getBoundingClientRect()
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
})
const rectBeforeOpen = await triggerRect()
await newSession.click({ button: "right" })
const popover = page.getByRole("dialog", { name: "Recent sessions" })
const search = popover.getByRole("combobox", { name: "Search sessions" })
const options = popover.getByRole("option")
await expect(popover).toBeVisible()
await expect.poll(triggerRect).toEqual(rectBeforeOpen)
await expect(newSession).toHaveAttribute("data-state", "pressed")
await expect(popover).toHaveCSS("width", "240px")
await expect(search).toBeFocused()
await expect(options).toHaveCount(5)
await expect
.poll(() => options.evaluateAll((items) => items.map((item) => item.getAttribute("aria-label"))))
.toEqual([longTitle, "Recent session 6", "Recent session 5", "Recent session 4", "Recent session 3"])
await expect(popover.getByRole("option", { name: longTitle })).toHaveAttribute("aria-selected", "true")
await expect(
popover.getByRole("option", { name: longTitle }).locator('[data-component="project-avatar-v2"][data-unread]'),
).toBeVisible()
await expect(
popover.getByRole("option", { name: "Recent session 6" }).locator('[data-component="project-avatar-v2"][data-unread]'),
).toHaveCount(0)
const title = popover.locator('[data-slot="titlebar-recent-session-title"]').filter({ hasText: longTitle })
await expect(title).toHaveCSS("text-overflow", "ellipsis")
await expect.poll(() => title.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
await search.press("Escape")
await expect(popover).toBeHidden()
await expect(newSession).not.toHaveAttribute("data-state")
await expect(newSession).toHaveAttribute("data-suppress-hover")
await expect(newSession).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await expect(newSession).toBeFocused()
await page.mouse.move(0, 100)
await expect(newSession).not.toHaveAttribute("data-suppress-hover")
await tabNav.getByRole("button", { name: "Home" }).click({ button: "right" })
await expect(popover).toBeHidden()
await blankTabNav.click({ button: "right" })
await expect(popover).toBeVisible()
await expect(newSession).toHaveAttribute("data-state", "pressed")
await expect(search).toBeFocused()
await search.press("Escape")
await newSession.click({ button: "right" })
await expect(search).toBeFocused()
const hovered = popover.getByRole("option", { name: "Recent session 4" })
const keyboardSelected = popover.getByRole("option", { name: "Recent session 3" })
await hovered.hover()
await expect(hovered).toHaveAttribute("aria-selected", "true")
await search.press("ArrowDown")
await expect(keyboardSelected).toHaveAttribute("aria-selected", "true")
await expect
.poll(async () => {
const [hoveredBackground, selectedBackground] = await Promise.all([
hovered.evaluate((element) => getComputedStyle(element).backgroundColor),
keyboardSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
])
return hoveredBackground !== selectedBackground
})
.toBe(true)
await search.fill("hidden search")
await expect(options).toHaveCount(1)
await expect(popover.getByRole("option", { name: "Hidden search target" })).toHaveAttribute("aria-selected", "true")
await search.fill("")
await expect(options).toHaveCount(5)
await search.press("ArrowDown")
await expect(popover.getByRole("option", { name: "Recent session 6" })).toHaveAttribute("aria-selected", "true")
await search.press("Enter")
await expect(page).toHaveURL(/\/session\/ses_6$/)
await expect(popover).toBeHidden()
await page.locator("[data-titlebar-tab]").filter({ hasText: "Recent session 6" }).click({ button: "right" })
await expect(popover).toBeHidden()
await page.keyboard.press("Escape")
await blankTabNav.click({ button: "right" })
await expect(popover).toBeVisible()
await expect(popover.getByRole("option", { name: "Recent session 6" })).toHaveCount(0)
await search.press("Escape")
await newSession.click()
await expect(page).toHaveURL(/\/new-session\?draftId=/)
await expect(popover).toBeHidden()
})
function session(id: string, title: string, updated: number) {
return {
id,
projectID: "proj_recent_sessions",
title,
location: { directory },
time: { created: updated, updated },
}
}
@@ -20,7 +20,6 @@ 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 { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import { sessionLabel, sessionTitle } from "@/session/title"
@@ -321,10 +320,6 @@ function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.location.directory)}:${record.session.id}`
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
+18 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { LocalProject } from "@/shell/state/layout"
import { buildHomeSessionRecords } from "./records"
import { buildHomeSessionRecords, filterHomeSessionRecords } from "./records"
const session = (id: string, directory: string, projectID: string) =>
({
@@ -59,3 +59,20 @@ describe("buildHomeSessionRecords", () => {
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
})
})
describe("filterHomeSessionRecords", () => {
test("matches session titles and project names case-insensitively", () => {
const projects = [
{ id: "project-a", worktree: "/repo/a", name: "Alpha", expanded: true },
{ id: "project-b", worktree: "/repo/b", name: "Beta", expanded: true },
] as LocalProject[]
const records = buildHomeSessionRecords({
sessions: () => [session("Title", "/repo/a", "project-a"), session("Other", "/repo/b", "project-b")],
projectDirectories: () => undefined,
projects: () => projects,
})
expect(filterHomeSessionRecords(records, "TITLE").map((record) => record.session.id)).toEqual(["Title"])
expect(filterHomeSessionRecords(records, "alpha").map((record) => record.session.id)).toEqual(["Title"])
})
})
+13
View File
@@ -2,6 +2,7 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
import type { LocalProject } from "@/shell/state/layout"
import { compareSessionTime, displayName } from "@/shell/layout/helpers"
import { pathKey } from "@/workspaces/path-key"
import { sessionLabel } from "@/session/title"
export type HomeSessionRecord = {
session: SessionInfo
@@ -31,6 +32,18 @@ export function buildHomeSessionRecords(input: {
})
}
export function filterHomeSessionRecords(records: HomeSessionRecord[], query: string) {
const value = query.trim().toLowerCase()
if (!value) return records
return records.filter((record) =>
`${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value),
)
}
export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.location.directory)}:${record.session.id}`
}
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
// the session's project ID still identifies its added project.
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
+4 -7
View File
@@ -2,12 +2,12 @@ import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { serverName } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { sessionLabel } from "@/session/title"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "../model"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
import type { HomeSessionsController } from "./controller"
import { filterHomeSessionRecords, homeSessionSearchKey, type HomeSessionRecord } from "./records"
import { looksLikeSessionID } from "@/session/search"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
@@ -28,11 +28,8 @@ export function createHomeSessionSearchController(home: HomeController, sessions
let list: HTMLDivElement | undefined
const query = createMemo(() => state.value.trim())
const results = createMemo(() => {
const value = query().toLowerCase()
if (!value) return []
const records = sessions.data
.searchRecords()
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
if (!query()) return []
const records = filterHomeSessionRecords(sessions.data.searchRecords(), query())
if (!state.exact || records.some((record) => record.session.id === state.exact?.session.id)) return records
return [state.exact, ...records]
})
+2 -7
View File
@@ -16,13 +16,8 @@ import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { sessionLabel } from "@/session/title"
import { shouldOpenSessionInBackground } from "./open"
import "./view.css"
import {
HomeSessionStatusController,
homeSessionSearchKey,
type HomeSessionGroup,
type HomeSessionRecord,
type OpenSessionOptions,
} from "./controller"
import { HomeSessionStatusController, type HomeSessionGroup, type OpenSessionOptions } from "./controller"
import { homeSessionSearchKey, type HomeSessionRecord } from "./records"
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test"
import { titlebarRecentSessionsPlacement } from "./titlebar-recent-sessions-popover-position"
describe("titlebar recent sessions placement", () => {
test("attaches to the start while the popover fits", () => {
expect(titlebarRecentSessionsPlacement({ left: 100, right: 128, viewportWidth: 800, direction: "ltr" })).toBe(
"bottom-start",
)
})
test("attaches to the end near the right edge", () => {
expect(titlebarRecentSessionsPlacement({ left: 570, right: 598, viewportWidth: 600, direction: "ltr" })).toBe(
"bottom-end",
)
})
test("uses the mirrored start edge in RTL", () => {
expect(titlebarRecentSessionsPlacement({ left: 4, right: 32, viewportWidth: 600, direction: "rtl" })).toBe(
"bottom-end",
)
expect(titlebarRecentSessionsPlacement({ left: 300, right: 328, viewportWidth: 600, direction: "rtl" })).toBe(
"bottom-start",
)
})
})
@@ -0,0 +1,13 @@
const POPOVER_WIDTH = 240
const VIEWPORT_GUTTER = 8
export function titlebarRecentSessionsPlacement(input: {
left: number
right: number
viewportWidth: number
direction: "ltr" | "rtl"
}) {
const start = input.direction === "rtl" ? input.right - POPOVER_WIDTH : input.left
const fits = start >= VIEWPORT_GUTTER && start + POPOVER_WIDTH <= input.viewportWidth - VIEWPORT_GUTTER
return fits ? ("bottom-start" as const) : ("bottom-end" as const)
}
@@ -0,0 +1,59 @@
[data-popper-positioner]:has([data-component="titlebar-recent-sessions-popover"]) {
z-index: 60;
}
[data-component="titlebar-recent-sessions-popover"] {
box-sizing: border-box;
width: 240px;
padding: 2px;
overflow: hidden;
border-radius: 6px;
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-floating);
outline: none;
transform-origin: var(--kb-popover-content-transform-origin);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="titlebar-recent-session-row"] {
box-sizing: border-box;
display: flex;
width: 100%;
height: 28px;
min-width: 0;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
cursor: default;
font-size: 13px;
font-weight: 440;
line-height: 20px;
letter-spacing: -0.04px;
outline: none;
text-align: start;
}
[data-component="titlebar-recent-sessions-popover"]:not([data-keyboard-navigation])
[data-component="titlebar-recent-session-row"]:hover,
[data-component="titlebar-recent-session-row"]:is([aria-selected="true"], :focus-visible) {
background: var(--v2-overlay-simple-overlay-hover);
}
[data-component="icon-button-v2"][data-variant="ghost-muted"][data-action="titlebar-new-session"][data-suppress-hover]:hover:not(
:disabled
) {
background-color: transparent;
color: var(--v2-icon-icon-muted);
}
@media (prefers-reduced-motion: reduce) {
[data-component="titlebar-recent-sessions-popover"] {
animation: none;
}
}
@@ -0,0 +1,306 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createMemo, createUniqueId, For, Show, startTransition, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import {
HOME_SESSION_LIMIT,
loadHomeSessionIndex,
mergeHomeSessionIndex,
retainHomeSessions,
} from "@/home/sessions/index"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import {
buildHomeSessionRecords,
filterHomeSessionRecords,
homeSessionSearchKey,
type HomeSessionRecord,
} from "@/home/sessions/records"
import { sessionLabel } from "@/session/title"
import { titlebarRecentSessionsPlacement } from "./titlebar-recent-sessions-popover-position"
import "./titlebar-recent-sessions-popover.css"
const RESULT_LIMIT = 5
const selectSessions = (sessions: SessionInfo[]) => () => sessions
export function TitlebarRecentSessionsPopover(props: {
server: ServerConnection.Any | undefined
label: string
tooltip: JSX.Element
onCreate: () => void
registerContextMenu: (handler: (event: MouseEvent) => void) => void
}) {
const global = useGlobal()
const tabs = useTabs()
const language = useLanguage()
const listID = createUniqueId()
const popoverID = createUniqueId()
const [state, setState] = createStore({
open: false,
query: "",
highlighted: "",
placement: "bottom-start" as "bottom-start" | "bottom-end",
restoreFocus: true,
keyboardNavigation: false,
suppressTriggerHover: false,
})
let anchor: HTMLDivElement | undefined
let button: HTMLButtonElement | undefined
let input: HTMLInputElement | undefined
const ctx = createMemo(() => (props.server ? global.ensureServerCtx(props.server) : undefined))
const serverKey = createMemo(() => (props.server ? ServerConnection.key(props.server) : undefined))
const sessionLoad = useQuery(() => {
const serverCtx = ctx()
return {
queryKey: ["home-sessions", props.server] as const,
enabled: state.open && !!serverCtx && serverCtx.sdk.connection.status() === "connected",
queryFn: serverCtx
? ({ signal }) =>
loadHomeSessionIndex((request, options) => serverCtx.sdk.api.session.list(request, options), signal)
: skipToken,
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
select: selectSessions,
}
})
const records = createMemo(() => {
const key = serverKey()
const serverCtx = ctx()
if (!key || !serverCtx) return []
return buildHomeSessionRecords({
sessions: () =>
retainHomeSessions(
serverCtx.data.session.apply(
mergeHomeSessionIndex(
sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []),
serverCtx.data.session.list(),
),
),
HOME_SESSION_LIMIT,
Date.now(),
),
projectDirectories: () => undefined,
projects: serverCtx.projects.list,
}).filter((record) => !sessionHasOpenTab(tabs.store, key, record.session))
})
const visible = createMemo(() => filterHomeSessionRecords(records(), state.query).slice(0, RESULT_LIMIT))
const active = createMemo(() => {
if (visible().some((record) => homeSessionSearchKey(record) === state.highlighted)) return state.highlighted
const first = visible()[0]
return first ? homeSessionSearchKey(first) : ""
})
const setOpen = (open: boolean) => {
if (open) {
setState({ open: true, restoreFocus: true, keyboardNavigation: false, suppressTriggerHover: false })
return
}
setState({ open: false, query: "", highlighted: "", keyboardNavigation: false })
}
const open = (event: MouseEvent) => {
event.preventDefault()
const element = anchor
const rect = element?.getBoundingClientRect()
if (element && rect) {
setState(
"placement",
titlebarRecentSessionsPlacement({
left: rect.left,
right: rect.right,
viewportWidth: window.innerWidth,
direction: getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr",
}),
)
}
setOpen(true)
}
const select = (record: HomeSessionRecord) => {
const server = props.server
const serverCtx = ctx()
if (!server || !serverCtx) return
const key = ServerConnection.key(server)
void serverCtx.data.session.message.sync(record.session.id).catch(() => undefined)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: key, sessionId: record.session.id })
tabs.select(tab)
serverCtx.data.session.remember(record.session)
serverCtx.projects.open(record.project.worktree)
serverCtx.projects.touch(record.project.worktree)
})
setOpen(false)
}
const move = (delta: number) => {
const list = visible()
if (list.length === 0) return
const index = list.findIndex((record) => homeSessionSearchKey(record) === active())
const next = ((index === -1 ? 0 : index) + delta + list.length) % list.length
setState({ highlighted: homeSessionSearchKey(list[next]), keyboardNavigation: true })
}
props.registerContextMenu(open)
return (
<Kobalte open={state.open} onOpenChange={setOpen} placement={state.placement} gutter={6} modal={false}>
<Kobalte.Anchor ref={anchor} as="div" class="shrink-0 [app-region:no-drag]" onContextMenu={open}>
<Tooltip forceOpen={state.open ? false : undefined} placement="bottom" value={props.tooltip}>
<IconButton
ref={button}
type="button"
data-action="titlebar-new-session"
data-suppress-hover={state.suppressTriggerHover || undefined}
variant="ghost-muted"
size="large"
state={state.open ? "pressed" : undefined}
class="shrink-0"
icon={<Icon name="plus" />}
onClick={props.onCreate}
onPointerLeave={() => setState("suppressTriggerHover", false)}
aria-label={props.label}
aria-haspopup="dialog"
aria-expanded={state.open}
aria-controls={state.open ? popoverID : undefined}
/>
</Tooltip>
</Kobalte.Anchor>
<Kobalte.Portal>
<Kobalte.Content
id={popoverID}
ref={(element) => {
const root = anchor?.closest("[data-theme]")
const theme = root?.getAttribute("data-theme")
const scheme = root?.getAttribute("data-color-scheme")
if (theme) element.setAttribute("data-theme", theme)
if (scheme) element.setAttribute("data-color-scheme", scheme)
element.dir = getComputedStyle(anchor ?? document.documentElement).direction
}}
data-component="titlebar-recent-sessions-popover"
data-keyboard-navigation={state.keyboardNavigation || undefined}
aria-label={language.t("sidebar.project.recentSessions")}
onOpenAutoFocus={(event) => {
event.preventDefault()
input?.focus({ preventScroll: true })
}}
onInteractOutside={() => setState("restoreFocus", false)}
onCloseAutoFocus={(event) => {
event.preventDefault()
if (state.restoreFocus && button?.matches(":hover")) setState("suppressTriggerHover", true)
if (state.restoreFocus) button?.focus({ preventScroll: true })
setState("restoreFocus", true)
}}
>
<label class="flex h-7 min-w-0 items-center gap-2 px-3 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" />
<input
ref={input}
type="text"
role="combobox"
value={state.query}
class="min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none [font-weight:440] placeholder:text-v2-text-text-faint"
placeholder={language.t("home.sessions.search.placeholder")}
aria-label={language.t("home.sessions.search.placeholder")}
aria-controls={listID}
aria-expanded={state.open}
aria-autocomplete="list"
aria-activedescendant={active() ? `${listID}-${active()}` : undefined}
onInput={(event) =>
setState({ query: event.currentTarget.value, highlighted: "", keyboardNavigation: true })
}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
setOpen(false)
return
}
if (event.altKey || event.metaKey || event.ctrlKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
move(-1)
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
const record = visible().find((item) => homeSessionSearchKey(item) === active())
if (record) select(record)
}}
/>
</label>
<div id={listID} role="listbox" aria-label={language.t("sidebar.project.recentSessions")}>
<Show
when={visible().length > 0}
fallback={
<div class="flex h-7 min-w-0 items-center justify-center overflow-hidden text-ellipsis whitespace-nowrap px-3 text-[13px] text-v2-text-text-muted [font-weight:440]">
<Show
when={sessionLoad.isPending}
fallback={
state.query
? language.t("home.sessions.search.noResults", { query: state.query.trim() })
: language.t("home.sessions.empty")
}
>
<Spinner class="size-4" />
</Show>
</div>
}
>
<For each={visible()}>
{(record) => {
const server = serverKey()
if (!server) return
const key = () => homeSessionSearchKey(record)
const title = () => sessionLabel(record.session)
return (
<button
type="button"
tabIndex={-1}
id={`${listID}-${key()}`}
role="option"
aria-selected={active() === key()}
aria-label={title()}
data-component="titlebar-recent-session-row"
onPointerMove={() => setState({ highlighted: key(), keyboardNavigation: false })}
onClick={() => select(record)}
>
<SessionTabAvatar
project={record.project}
directory={record.session.location.directory}
sessionId={record.session.id}
server={server}
revealProjectOnHover={false}
/>
<span
data-slot="titlebar-recent-session-title"
dir="auto"
class="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
>
{title()}
</span>
</button>
)
}}
</For>
</Show>
</div>
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}
+28 -15
View File
@@ -28,6 +28,7 @@ import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionTabTitle } from "./tab-title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { TitlebarRecentSessionsPopover } from "./titlebar-recent-sessions-popover"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
@@ -319,6 +320,12 @@ export function Titlebar(props: {
}
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
const recentSessionsServer = () => {
const route = layout.route()
const key =
route.type === "session" ? route.server : (currentTab()?.server ?? layout.home.selection().server)
return global.servers.list().find((item) => ServerConnection.key(item) === key)
}
const homeButton = (vertical = false) => (
<Show
when={vertical}
@@ -434,9 +441,11 @@ export function Titlebar(props: {
mobile()
setMobileTabs("open", false)
})
let openRecentSessions: ((event: MouseEvent) => void) | undefined
return (
<div
data-slot="titlebar-tab-nav"
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
classList={{
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
@@ -445,6 +454,10 @@ export function Titlebar(props: {
// Center the 20px app icon over the sidebar's 16px icon column.
"ps-3.5": windows(),
}}
onContextMenu={(event) => {
if (titlebarTabNavContextMenuBlocked(event.target)) return
openRecentSessions?.(event)
}}
>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator horizontal debugTools={props.debugTools} />
@@ -607,25 +620,18 @@ export function Titlebar(props: {
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Tooltip
placement="bottom"
value={
<TitlebarRecentSessionsPopover
server={recentSessionsServer()}
label={language.t("command.session.new")}
tooltip={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
onCreate={openNewTab}
registerContextMenu={(handler) => (openRecentSessions = handler)}
/>
</>
}
>
@@ -690,7 +696,7 @@ export function Titlebar(props: {
</Show>
</Show>
<Show when={!mobile()}>
<div class="flex-1" />
<div data-slot="titlebar-tab-nav-blank" class="flex-1 self-stretch" />
</Show>
<TitlebarRight state={rightState()} mount={!props.verticalTabs} />
</div>
@@ -702,6 +708,13 @@ export function Titlebar(props: {
)
}
function titlebarTabNavContextMenuBlocked(target: EventTarget | null) {
if (!(target instanceof Element)) return true
return !!target.closest(
'[data-titlebar-tab], [data-action], button, a, input, select, textarea, [role="button"], [role="menuitem"], [role="option"], [contenteditable="true"]',
)
}
type TitlebarUpdatePillState = {
visible: boolean
installing: boolean