551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
import type { Session } from "@opencode-ai/sdk/v2/client"
|
|
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
|
|
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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
import { useLanguage } from "@/context/language"
|
|
import { ServerConnection } from "@/context/server"
|
|
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
|
import { sessionTitle } from "@/utils/session-title"
|
|
import { shouldOpenSessionInBackground } from "../home-session-open"
|
|
import {
|
|
HomeSessionStatusController,
|
|
homeSessionSearchKey,
|
|
type HomeSessionGroup,
|
|
type HomeSessionRecord,
|
|
type OpenSessionOptions,
|
|
} from "./home-sessions-controller"
|
|
|
|
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"
|
|
|
|
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
|
|
// tab in the background without navigating, matching browser conventions.
|
|
function isBackgroundOpen(event: MouseEvent) {
|
|
return shouldOpenSessionInBackground({
|
|
button: event.button,
|
|
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
|
|
meta: event.metaKey,
|
|
ctrl: event.ctrlKey,
|
|
shift: event.shiftKey,
|
|
alt: event.altKey,
|
|
})
|
|
}
|
|
|
|
export type HomeSessionsViewProps = {
|
|
language: ReturnType<typeof useLanguage>
|
|
groups: Accessor<HomeSessionGroup[]>
|
|
showProjectName: Accessor<boolean>
|
|
server: Accessor<ServerConnection.Key>
|
|
canCreateSession: Accessor<boolean>
|
|
searchValue: Accessor<string>
|
|
searchPlaceholder: Accessor<string>
|
|
searchOpen: Accessor<boolean>
|
|
searchLoading: Accessor<boolean>
|
|
searchResults: Accessor<HomeSessionRecord[]>
|
|
searchActive: Accessor<string>
|
|
searchNoResultsLabel: Accessor<string>
|
|
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
|
isOpenTab: (record: HomeSessionRecord) => boolean
|
|
onCreateSession: () => void
|
|
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
|
|
onArchiveSession: (session: Session) => Promise<void>
|
|
onSetHoverTarget: (element: HTMLElement) => void
|
|
onSetThumbTrack: (element: HTMLDivElement) => void
|
|
onSetContent: (element: HTMLDivElement) => void
|
|
onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void
|
|
onWheel: (event: WheelEvent) => void
|
|
onSetSearchRoot: (element: HTMLDivElement) => void
|
|
onSetSearchInput: (element: HTMLInputElement) => void
|
|
onSetSearchList: (element: HTMLDivElement) => void
|
|
onSearchFocus: () => void
|
|
onSearchInput: (value: string) => void
|
|
onSearchClose: () => void
|
|
onSearchMove: (delta: number) => void
|
|
onSearchSelectActive: () => void
|
|
onSearchHighlight: (record: HomeSessionRecord) => void
|
|
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
|
|
}
|
|
|
|
export function HomeSessionsView(props: HomeSessionsViewProps) {
|
|
return (
|
|
<section
|
|
ref={props.onSetHoverTarget}
|
|
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
|
aria-label={props.language.t("sidebar.project.recentSessions")}
|
|
>
|
|
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
|
<HomeSessionSearch {...props} />
|
|
<Suspense>
|
|
<Show when={props.groups().length > 0 && props.canCreateSession()}>
|
|
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
|
<ButtonV2
|
|
data-action="home-new-session"
|
|
variant="ghost-muted"
|
|
size="normal"
|
|
icon="edit"
|
|
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
|
onClick={props.onCreateSession}
|
|
>
|
|
{props.language.t("command.session.new")}
|
|
</ButtonV2>
|
|
</div>
|
|
</Show>
|
|
</Suspense>
|
|
</div>
|
|
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
|
<div
|
|
ref={props.onSetThumbTrack}
|
|
data-component="home-session-scroll-track"
|
|
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
|
/>
|
|
</div>
|
|
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
|
<Suspense
|
|
fallback={
|
|
<div class="pt-3">
|
|
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
|
</div>
|
|
}
|
|
>
|
|
<Show
|
|
when={props.groups().length > 0}
|
|
fallback={
|
|
<HomeSessionsEmpty
|
|
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
|
|
language={props.language}
|
|
/>
|
|
}
|
|
>
|
|
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
|
<For each={props.groups()}>
|
|
{(group, index) => (
|
|
<>
|
|
<HomeSessionGroupHeader
|
|
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>
|
|
</>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function HomeSessionLeadingController(props: {
|
|
server: HomeSessionsViewProps["server"]
|
|
isOpenTab: HomeSessionsViewProps["isOpenTab"]
|
|
record: HomeSessionRecord
|
|
revealProjectOnHover: boolean
|
|
}) {
|
|
return (
|
|
<HomeSessionStatusController
|
|
server={props.server}
|
|
record={props.record}
|
|
isOpenTab={props.isOpenTab}
|
|
render={(state) => (
|
|
<HomeSessionLeading
|
|
record={props.record}
|
|
revealProjectOnHover={props.revealProjectOnHover}
|
|
open={state.open()}
|
|
unread={state.unread()}
|
|
loading={state.loading()}
|
|
/>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function HomeSessionLeading(props: {
|
|
record: HomeSessionRecord
|
|
revealProjectOnHover: boolean
|
|
open: boolean
|
|
unread: boolean
|
|
loading: boolean
|
|
}) {
|
|
return (
|
|
<div class="relative shrink-0">
|
|
<Show when={props.open}>
|
|
<span
|
|
aria-hidden="true"
|
|
class={`
|
|
pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2
|
|
rounded-[2px] bg-v2-background-bg-layer-04
|
|
`}
|
|
style={{ right: "calc(100% + 4px)" }}
|
|
/>
|
|
</Show>
|
|
<SessionTabAvatarView
|
|
project={props.record.project}
|
|
directory={props.record.session.directory}
|
|
revealProjectOnHover={props.revealProjectOnHover}
|
|
unread={props.unread}
|
|
loading={props.loading}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|
return (
|
|
<div class="w-full">
|
|
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
|
|
<Show when={props.searchOpen()}>
|
|
<div
|
|
data-component="home-session-search-panel"
|
|
class={`
|
|
absolute flex flex-col overflow-hidden rounded-[12px]
|
|
bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
|
|
`}
|
|
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
|
|
>
|
|
<div class="flex flex-col pt-9">
|
|
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
|
<Show
|
|
when={!props.searchLoading()}
|
|
fallback={
|
|
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
|
|
<Spinner class="size-4" />
|
|
</div>
|
|
}
|
|
>
|
|
<Show
|
|
when={props.searchResults().length > 0}
|
|
fallback={
|
|
<p
|
|
class={`
|
|
my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px]
|
|
text-v2-text-text-muted [font-weight:440]
|
|
`}
|
|
>
|
|
{props.searchNoResultsLabel()}
|
|
</p>
|
|
}
|
|
>
|
|
<div class="flex flex-col">
|
|
<p
|
|
class={`
|
|
my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px]
|
|
text-v2-text-text-muted [font-weight:440]
|
|
`}
|
|
>
|
|
{props.language.t("home.sessions.search.sessions")}
|
|
</p>
|
|
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
|
<div class="flex flex-col gap-px pb-2">
|
|
<For each={props.searchResults()}>
|
|
{(record) => (
|
|
<HomeSessionSearchResultRow
|
|
{...props}
|
|
record={record}
|
|
selected={props.searchActive() === homeSessionSearchKey(record)}
|
|
/>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</ScrollView>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
<label
|
|
class={`
|
|
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
|
|
bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
|
|
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
|
|
`}
|
|
>
|
|
<IconV2 name="magnifying-glass" />
|
|
<input
|
|
ref={props.onSetSearchInput}
|
|
class={`
|
|
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
|
|
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
|
|
`}
|
|
value={props.searchValue()}
|
|
placeholder={props.searchPlaceholder()}
|
|
aria-label={props.searchPlaceholder()}
|
|
aria-expanded={props.searchOpen()}
|
|
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={
|
|
props.searchActive() && props.searchOpen()
|
|
? `home-session-search-option-${props.searchActive()}`
|
|
: undefined
|
|
}
|
|
onFocus={props.onSearchFocus}
|
|
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault()
|
|
props.onSearchClose()
|
|
event.currentTarget.blur()
|
|
return
|
|
}
|
|
if (!props.searchOpen() || props.searchResults().length === 0) return
|
|
if (event.altKey || event.metaKey) return
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault()
|
|
props.onSearchMove(1)
|
|
return
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault()
|
|
props.onSearchMove(-1)
|
|
return
|
|
}
|
|
if (event.key === "Enter" && !event.isComposing) {
|
|
event.preventDefault()
|
|
props.onSearchSelectActive()
|
|
}
|
|
}}
|
|
/>
|
|
<Show when={props.searchValue()}>
|
|
<IconButtonV2
|
|
type="button"
|
|
variant="ghost-muted"
|
|
size="small"
|
|
class="relative z-20 shrink-0"
|
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
|
aria-label={props.searchPlaceholder()}
|
|
onClick={() => {
|
|
props.onSearchClose()
|
|
props.onSearchFocus()
|
|
}}
|
|
/>
|
|
</Show>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HomeSessionSearchResultRow(
|
|
props: HomeSessionsViewProps & {
|
|
record: HomeSessionRecord
|
|
selected: boolean
|
|
},
|
|
) {
|
|
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
|
const key = () => homeSessionSearchKey(props.record)
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
id={`home-session-search-option-${key()}`}
|
|
data-key={key()}
|
|
data-component="home-session-search-row"
|
|
role="option"
|
|
aria-selected={props.selected}
|
|
class={`
|
|
flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left
|
|
transition-[background-color] duration-[120ms] ease-in-out
|
|
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
|
`}
|
|
classList={{
|
|
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
|
group: !!showProjectName(),
|
|
}}
|
|
onMouseEnter={() => props.onSearchHighlight(props.record)}
|
|
onMouseDown={(event) => {
|
|
if (event.button === 1) event.preventDefault()
|
|
}}
|
|
onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
|
|
onAuxClick={(event) => {
|
|
if (!isBackgroundOpen(event)) return
|
|
event.preventDefault()
|
|
props.onSearchSelect(props.record, { background: true })
|
|
}}
|
|
>
|
|
<HomeSessionLeadingController
|
|
server={props.server}
|
|
isOpenTab={props.isOpenTab}
|
|
record={props.record}
|
|
revealProjectOnHover={!!showProjectName()}
|
|
/>
|
|
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
|
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
|
|
<Show when={showProjectName()}>
|
|
<HomeSessionProjectName name={props.record.projectName} search />
|
|
</Show>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function HomeSessionGroupHeader(props: {
|
|
title: string
|
|
titleOpacity: number
|
|
onSetRef: (element: HTMLDivElement) => void
|
|
elevated?: boolean
|
|
}) {
|
|
return (
|
|
<div
|
|
ref={props.onSetRef}
|
|
class={`
|
|
pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
|
|
bg-v2-background-bg-base pl-3 lg:top-[108px]
|
|
`}
|
|
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
|
|
>
|
|
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
|
{props.title}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
|
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
|
|
|
return (
|
|
<div
|
|
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
|
classList={{ group: !!showProjectName() }}
|
|
>
|
|
<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 })
|
|
}}
|
|
>
|
|
<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 when={SHOW_HOME_SESSION_ARCHIVE}>
|
|
<div
|
|
class={`
|
|
hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1
|
|
group-hover/session:opacity-100 focus-within:opacity-100
|
|
`}
|
|
>
|
|
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
|
<IconButtonV2
|
|
data-action="home-session-archive"
|
|
variant="ghost-muted"
|
|
size="large"
|
|
icon={<IconV2 name="archive" />}
|
|
aria-label={props.language.t("common.archive")}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
void props.onArchiveSession(props.record.session)
|
|
}}
|
|
/>
|
|
</TooltipV2>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
|
|
return (
|
|
<span
|
|
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,
|
|
"max-w-[min(70%,480px)] flex-[0_1_auto]": props.showProjectName,
|
|
"flex-[1_1_auto]": !props.showProjectName,
|
|
}}
|
|
>
|
|
{props.title}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
|
|
return (
|
|
<span
|
|
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
|
|
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
|
|
>
|
|
{props.name}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType<typeof useLanguage> }) {
|
|
return (
|
|
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
|
<div
|
|
class={`
|
|
shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
|
|
text-v2-text-text-base [font-weight:530]
|
|
`}
|
|
>
|
|
{props.language.t("home.sessions.empty")}
|
|
</div>
|
|
<p
|
|
class={`
|
|
mb-1 text-center text-[13px] leading-5 tracking-[-0.04px]
|
|
text-v2-text-text-muted [font-weight:440]
|
|
`}
|
|
>
|
|
{props.language.t("home.sessions.empty.description")}
|
|
</p>
|
|
<Show when={props.onNewSession}>
|
|
{(onNewSession) => (
|
|
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
|
|
{props.language.t("command.session.new")}
|
|
</ButtonV2>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HomeSessionSkeleton(props: { label: string }) {
|
|
return (
|
|
<div class="flex min-w-0 flex-col gap-4">
|
|
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
|
<div class={HOME_SECTION_LABEL}>{props.label}</div>
|
|
</div>
|
|
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
|
|
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|