553 lines
20 KiB
TypeScript
553 lines
20 KiB
TypeScript
import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js"
|
|
import { createStore, produce } from "solid-js/store"
|
|
import { useNavigate, useParams } from "@solidjs/router"
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
import { Icon } from "@opencode-ai/ui/icon"
|
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
|
import { showToast } from "@opencode-ai/ui/toast"
|
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
import { useLanguage } from "@/context/language"
|
|
import { useSDK } from "@/context/sdk"
|
|
import { useSync } from "@/context/sync"
|
|
|
|
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
|
const current = target instanceof Element ? target : undefined
|
|
const nested = current?.closest("[data-scrollable]")
|
|
if (!nested || nested === root) return root
|
|
if (!(nested instanceof HTMLElement)) return root
|
|
return nested
|
|
}
|
|
|
|
const markBoundaryGesture = (input: {
|
|
root: HTMLDivElement
|
|
target: EventTarget | null
|
|
delta: number
|
|
onMarkScrollGesture: (target?: EventTarget | null) => void
|
|
}) => {
|
|
const target = boundaryTarget(input.root, input.target)
|
|
if (target === input.root) {
|
|
input.onMarkScrollGesture(input.root)
|
|
return
|
|
}
|
|
if (
|
|
shouldMarkBoundaryGesture({
|
|
delta: input.delta,
|
|
scrollTop: target.scrollTop,
|
|
scrollHeight: target.scrollHeight,
|
|
clientHeight: target.clientHeight,
|
|
})
|
|
) {
|
|
input.onMarkScrollGesture(input.root)
|
|
}
|
|
}
|
|
|
|
export function MessageTimeline(props: {
|
|
mobileChanges: boolean
|
|
mobileFallback: JSX.Element
|
|
scroll: { overflow: boolean; bottom: boolean }
|
|
onResumeScroll: () => void
|
|
setScrollRef: (el: HTMLDivElement | undefined) => void
|
|
onScheduleScrollState: (el: HTMLDivElement) => void
|
|
onAutoScrollHandleScroll: () => void
|
|
onMarkScrollGesture: (target?: EventTarget | null) => void
|
|
hasScrollGesture: () => boolean
|
|
isDesktop: boolean
|
|
onScrollSpyScroll: () => void
|
|
onAutoScrollInteraction: (event: MouseEvent) => void
|
|
centered: boolean
|
|
setContentRef: (el: HTMLDivElement) => void
|
|
turnStart: number
|
|
onRenderEarlier: () => void
|
|
historyMore: boolean
|
|
historyLoading: boolean
|
|
onLoadEarlier: () => void
|
|
renderedUserMessages: UserMessage[]
|
|
anchor: (id: string) => string
|
|
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
|
onUnregisterMessage: (id: string) => void
|
|
lastUserMessageID?: string
|
|
}) {
|
|
let touchGesture: number | undefined
|
|
|
|
const params = useParams()
|
|
const navigate = useNavigate()
|
|
const sdk = useSDK()
|
|
const sync = useSync()
|
|
const dialog = useDialog()
|
|
const language = useLanguage()
|
|
|
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
const sessionID = createMemo(() => params.id)
|
|
const info = createMemo(() => {
|
|
const id = sessionID()
|
|
if (!id) return
|
|
return sync.session.get(id)
|
|
})
|
|
const titleValue = createMemo(() => info()?.title)
|
|
const parentID = createMemo(() => info()?.parentID)
|
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
|
|
|
const [title, setTitle] = createStore({
|
|
draft: "",
|
|
editing: false,
|
|
saving: false,
|
|
menuOpen: false,
|
|
pendingRename: false,
|
|
})
|
|
let titleRef: HTMLInputElement | undefined
|
|
|
|
const errorMessage = (err: unknown) => {
|
|
if (err && typeof err === "object" && "data" in err) {
|
|
const data = (err as { data?: { message?: string } }).data
|
|
if (data?.message) return data.message
|
|
}
|
|
if (err instanceof Error) return err.message
|
|
return language.t("common.requestFailed")
|
|
}
|
|
|
|
createEffect(
|
|
on(
|
|
sessionKey,
|
|
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
const openTitleEditor = () => {
|
|
if (!sessionID()) return
|
|
setTitle({ editing: true, draft: titleValue() ?? "" })
|
|
requestAnimationFrame(() => {
|
|
titleRef?.focus()
|
|
titleRef?.select()
|
|
})
|
|
}
|
|
|
|
const closeTitleEditor = () => {
|
|
if (title.saving) return
|
|
setTitle({ editing: false, saving: false })
|
|
}
|
|
|
|
const saveTitleEditor = async () => {
|
|
const id = sessionID()
|
|
if (!id) return
|
|
if (title.saving) return
|
|
|
|
const next = title.draft.trim()
|
|
if (!next || next === (titleValue() ?? "")) {
|
|
setTitle({ editing: false, saving: false })
|
|
return
|
|
}
|
|
|
|
setTitle("saving", true)
|
|
await sdk.client.session
|
|
.update({ sessionID: id, title: next })
|
|
.then(() => {
|
|
sync.set(
|
|
produce((draft) => {
|
|
const index = draft.session.findIndex((s) => s.id === id)
|
|
if (index !== -1) draft.session[index].title = next
|
|
}),
|
|
)
|
|
setTitle({ editing: false, saving: false })
|
|
})
|
|
.catch((err) => {
|
|
setTitle("saving", false)
|
|
showToast({
|
|
title: language.t("common.requestFailed"),
|
|
description: errorMessage(err),
|
|
})
|
|
})
|
|
}
|
|
|
|
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
|
if (params.id !== sessionID) return
|
|
if (parentID) {
|
|
navigate(`/${params.dir}/session/${parentID}`)
|
|
return
|
|
}
|
|
if (nextSessionID) {
|
|
navigate(`/${params.dir}/session/${nextSessionID}`)
|
|
return
|
|
}
|
|
navigate(`/${params.dir}/session`)
|
|
}
|
|
|
|
const archiveSession = async (sessionID: string) => {
|
|
const session = sync.session.get(sessionID)
|
|
if (!session) return
|
|
|
|
const sessions = sync.data.session ?? []
|
|
const index = sessions.findIndex((s) => s.id === sessionID)
|
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
|
|
|
await sdk.client.session
|
|
.update({ sessionID, time: { archived: Date.now() } })
|
|
.then(() => {
|
|
sync.set(
|
|
produce((draft) => {
|
|
const index = draft.session.findIndex((s) => s.id === sessionID)
|
|
if (index !== -1) draft.session.splice(index, 1)
|
|
}),
|
|
)
|
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
|
})
|
|
.catch((err) => {
|
|
showToast({
|
|
title: language.t("common.requestFailed"),
|
|
description: errorMessage(err),
|
|
})
|
|
})
|
|
}
|
|
|
|
const deleteSession = async (sessionID: string) => {
|
|
const session = sync.session.get(sessionID)
|
|
if (!session) return false
|
|
|
|
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
|
const index = sessions.findIndex((s) => s.id === sessionID)
|
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
|
|
|
const result = await sdk.client.session
|
|
.delete({ sessionID })
|
|
.then((x) => x.data)
|
|
.catch((err) => {
|
|
showToast({
|
|
title: language.t("session.delete.failed.title"),
|
|
description: errorMessage(err),
|
|
})
|
|
return false
|
|
})
|
|
|
|
if (!result) return false
|
|
|
|
sync.set(
|
|
produce((draft) => {
|
|
const removed = new Set<string>([sessionID])
|
|
|
|
const byParent = new Map<string, string[]>()
|
|
for (const item of draft.session) {
|
|
const parentID = item.parentID
|
|
if (!parentID) continue
|
|
const existing = byParent.get(parentID)
|
|
if (existing) {
|
|
existing.push(item.id)
|
|
continue
|
|
}
|
|
byParent.set(parentID, [item.id])
|
|
}
|
|
|
|
const stack = [sessionID]
|
|
while (stack.length) {
|
|
const parentID = stack.pop()
|
|
if (!parentID) continue
|
|
|
|
const children = byParent.get(parentID)
|
|
if (!children) continue
|
|
|
|
for (const child of children) {
|
|
if (removed.has(child)) continue
|
|
removed.add(child)
|
|
stack.push(child)
|
|
}
|
|
}
|
|
|
|
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
|
}),
|
|
)
|
|
|
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
|
return true
|
|
}
|
|
|
|
const navigateParent = () => {
|
|
const id = parentID()
|
|
if (!id) return
|
|
navigate(`/${params.dir}/session/${id}`)
|
|
}
|
|
|
|
function DialogDeleteSession(props: { sessionID: string }) {
|
|
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
|
const handleDelete = async () => {
|
|
await deleteSession(props.sessionID)
|
|
dialog.close()
|
|
}
|
|
|
|
return (
|
|
<Dialog title={language.t("session.delete.title")} fit>
|
|
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-14-regular text-text-strong">
|
|
{language.t("session.delete.confirm", { name: name() })}
|
|
</span>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
|
{language.t("common.cancel")}
|
|
</Button>
|
|
<Button variant="primary" size="large" onClick={handleDelete}>
|
|
{language.t("session.delete.button")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Show
|
|
when={!props.mobileChanges}
|
|
fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>}
|
|
>
|
|
<div class="relative w-full h-full min-w-0">
|
|
<div
|
|
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
|
classList={{
|
|
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
|
|
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
|
|
}}
|
|
>
|
|
<button
|
|
class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
|
|
onClick={props.onResumeScroll}
|
|
>
|
|
<Icon name="arrow-down-to-line" />
|
|
</button>
|
|
</div>
|
|
<div
|
|
ref={props.setScrollRef}
|
|
onWheel={(e) => {
|
|
const root = e.currentTarget
|
|
const delta = normalizeWheelDelta({
|
|
deltaY: e.deltaY,
|
|
deltaMode: e.deltaMode,
|
|
rootHeight: root.clientHeight,
|
|
})
|
|
if (!delta) return
|
|
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
|
}}
|
|
onTouchStart={(e) => {
|
|
touchGesture = e.touches[0]?.clientY
|
|
}}
|
|
onTouchMove={(e) => {
|
|
const next = e.touches[0]?.clientY
|
|
const prev = touchGesture
|
|
touchGesture = next
|
|
if (next === undefined || prev === undefined) return
|
|
|
|
const delta = prev - next
|
|
if (!delta) return
|
|
|
|
const root = e.currentTarget
|
|
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
|
}}
|
|
onTouchEnd={() => {
|
|
touchGesture = undefined
|
|
}}
|
|
onTouchCancel={() => {
|
|
touchGesture = undefined
|
|
}}
|
|
onPointerDown={(e) => {
|
|
if (e.target !== e.currentTarget) return
|
|
props.onMarkScrollGesture(e.currentTarget)
|
|
}}
|
|
onScroll={(e) => {
|
|
props.onScheduleScrollState(e.currentTarget)
|
|
if (!props.hasScrollGesture()) return
|
|
props.onAutoScrollHandleScroll()
|
|
props.onMarkScrollGesture(e.currentTarget)
|
|
if (props.isDesktop) props.onScrollSpyScroll()
|
|
}}
|
|
onClick={props.onAutoScrollInteraction}
|
|
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
|
style={{
|
|
"--session-title-height": showHeader() ? "40px" : "0px",
|
|
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
|
}}
|
|
>
|
|
<Show when={showHeader()}>
|
|
<div
|
|
classList={{
|
|
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
|
"w-full": true,
|
|
"pb-4": true,
|
|
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
|
}}
|
|
>
|
|
<div class="h-12 w-full flex items-center justify-between gap-2">
|
|
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
|
<Show when={parentID()}>
|
|
<IconButton
|
|
tabIndex={-1}
|
|
icon="arrow-left"
|
|
variant="ghost"
|
|
onClick={navigateParent}
|
|
aria-label={language.t("common.goBack")}
|
|
/>
|
|
</Show>
|
|
<Show when={titleValue() || title.editing}>
|
|
<Show
|
|
when={title.editing}
|
|
fallback={
|
|
<h1
|
|
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
|
onDblClick={openTitleEditor}
|
|
>
|
|
{titleValue()}
|
|
</h1>
|
|
}
|
|
>
|
|
<InlineInput
|
|
ref={(el) => {
|
|
titleRef = el
|
|
}}
|
|
value={title.draft}
|
|
disabled={title.saving}
|
|
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
|
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
event.stopPropagation()
|
|
if (event.key === "Enter") {
|
|
event.preventDefault()
|
|
void saveTitleEditor()
|
|
return
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault()
|
|
closeTitleEditor()
|
|
}
|
|
}}
|
|
onBlur={closeTitleEditor}
|
|
/>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
<Show when={sessionID()}>
|
|
{(id) => (
|
|
<div class="shrink-0 flex items-center gap-3">
|
|
<SessionContextUsage placement="bottom" />
|
|
<DropdownMenu
|
|
gutter={4}
|
|
placement="bottom-end"
|
|
open={title.menuOpen}
|
|
onOpenChange={(open) => setTitle("menuOpen", open)}
|
|
>
|
|
<DropdownMenu.Trigger
|
|
as={IconButton}
|
|
icon="dot-grid"
|
|
variant="ghost"
|
|
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
aria-label={language.t("common.moreOptions")}
|
|
/>
|
|
<DropdownMenu.Portal>
|
|
<DropdownMenu.Content
|
|
style={{ "min-width": "104px" }}
|
|
onCloseAutoFocus={(event) => {
|
|
if (!title.pendingRename) return
|
|
event.preventDefault()
|
|
setTitle("pendingRename", false)
|
|
openTitleEditor()
|
|
}}
|
|
>
|
|
<DropdownMenu.Item
|
|
onSelect={() => {
|
|
setTitle("pendingRename", true)
|
|
setTitle("menuOpen", false)
|
|
}}
|
|
>
|
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
</DropdownMenu.Item>
|
|
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
|
</DropdownMenu.Item>
|
|
<DropdownMenu.Separator />
|
|
<DropdownMenu.Item
|
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
|
>
|
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
|
</DropdownMenu.Item>
|
|
</DropdownMenu.Content>
|
|
</DropdownMenu.Portal>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div
|
|
ref={props.setContentRef}
|
|
role="log"
|
|
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
|
classList={{
|
|
"w-full": true,
|
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
|
"mt-0.5": props.centered,
|
|
"mt-0": !props.centered,
|
|
}}
|
|
>
|
|
<Show when={props.turnStart > 0}>
|
|
<div class="w-full flex justify-center">
|
|
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
|
|
{language.t("session.messages.renderEarlier")}
|
|
</Button>
|
|
</div>
|
|
</Show>
|
|
<Show when={props.historyMore}>
|
|
<div class="w-full flex justify-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="large"
|
|
class="text-12-medium opacity-50"
|
|
disabled={props.historyLoading}
|
|
onClick={props.onLoadEarlier}
|
|
>
|
|
{props.historyLoading
|
|
? language.t("session.messages.loadingEarlier")
|
|
: language.t("session.messages.loadEarlier")}
|
|
</Button>
|
|
</div>
|
|
</Show>
|
|
<For each={props.renderedUserMessages}>
|
|
{(message) => (
|
|
<div
|
|
id={props.anchor(message.id)}
|
|
data-message-id={message.id}
|
|
ref={(el) => {
|
|
props.onRegisterMessage(el, message.id)
|
|
onCleanup(() => props.onUnregisterMessage(message.id))
|
|
}}
|
|
classList={{
|
|
"min-w-0 w-full max-w-full": true,
|
|
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
|
}}
|
|
>
|
|
<SessionTurn
|
|
sessionID={sessionID() ?? ""}
|
|
messageID={message.id}
|
|
lastUserMessageID={props.lastUserMessageID}
|
|
classes={{
|
|
root: "min-w-0 w-full relative",
|
|
content: "flex flex-col justify-between !overflow-visible",
|
|
container: "w-full px-4 md:px-5",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
)
|
|
}
|