mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 03:16:23 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573b6e3670 | ||
|
|
dc5d2b08d1 | ||
|
|
44f0f1a562 | ||
|
|
49bc1cf6bb | ||
|
|
b263d8e77f | ||
|
|
7582f87949 |
@@ -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 })
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[] }>(
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user