diff options
| author | Kit Langton <[email protected]> | 2026-03-07 06:25:22 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-07 05:25:22 -0600 |
| commit | bbd0f3a25283b6f9567a04e79d7f6972950ab0a6 (patch) | |
| tree | 56cb41ff9c67749c6fa894fef32bb14cefd73db3 /packages/app/src | |
| parent | b7e208b4f1e6641a1cbb1e13f59789c7b7f4c60a (diff) | |
| download | opencode-bbd0f3a25283b6f9567a04e79d7f6972950ab0a6.tar.gz opencode-bbd0f3a25283b6f9567a04e79d7f6972950ab0a6.zip | |
STUPID SEXY TIMELINE (#16420)
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/pages/session.tsx | 21 | ||||
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 501 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-timeline-header.tsx | 522 | ||||
| -rw-r--r-- | packages/app/src/pages/session/use-session-hash-scroll.ts | 52 |
4 files changed, 686 insertions, 410 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 82a581e68..578dadecf 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -121,13 +121,9 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { return } const beforeTop = el.scrollTop - const beforeHeight = el.scrollHeight fn() - requestAnimationFrame(() => { - const delta = el.scrollHeight - beforeHeight - if (!delta) return - el.scrollTop = beforeTop + delta - }) + void el.scrollHeight + el.scrollTop = beforeTop } const backfillTurns = () => { @@ -210,7 +206,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { if (!input.userScrolled()) return const el = input.scroller() if (!el) return - if (el.scrollTop >= turnScrollThreshold) return + if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return const start = turnStart() if (start > 0) { @@ -1110,7 +1106,7 @@ export default function Page() { const updateScrollState = (el: HTMLDivElement) => { const max = el.scrollHeight - el.clientHeight const overflow = max > 1 - const bottom = !overflow || el.scrollTop >= max - 2 + const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled() if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return setUi("scroll", { overflow, bottom }) @@ -1133,7 +1129,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.smoothScrollToBottom() clearMessageHash() const el = scroller @@ -1201,13 +1197,11 @@ export default function Page() { const el = scroller const delta = next - dockHeight - const stick = el - ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) - : false + const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false dockHeight = next - if (stick) autoScroll.forceScrollToBottom() + if (stick) autoScroll.smoothScrollToBottom() if (el) scheduleScrollState(el) scrollSpy.markDirty() @@ -1293,6 +1287,7 @@ export default function Page() { onScrollSpyScroll={scrollSpy.onScroll} onTurnBackfillScroll={historyWindow.onScrollerScroll} onAutoScrollInteraction={autoScroll.handleInteraction} + onPreserveScrollAnchor={autoScroll.preserve} centered={centered()} setContentRef={(el) => { content = el diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ce6a01378..938ff4fbd 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,27 +1,31 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" +import { + For, + Index, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + Show, + startTransition, + type JSX, +} from "solid-js" +import { createStore } from "solid-js/store" +import { useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { FileIcon } from "@opencode-ai/ui/file-icon" 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 { ScrollView } from "@opencode-ai/ui/scroll-view" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" -import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" 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 { useSettings } from "@/context/settings" -import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" +import { SessionTimelineHeader } from "@/pages/session/session-timeline-header" type MessageComment = { path: string @@ -33,7 +37,9 @@ type MessageComment = { } const emptyMessages: MessageType[] = [] -const idle = { type: "idle" as const } + +const isDefaultSessionTitle = (title?: string) => + !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { @@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) { completedSession: "", count: 0, }) + const [readySession, setReadySession] = createSignal("") + let active = "" const stagedCount = createMemo(() => { const total = input.messages().length @@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) { cancelAnimationFrame(frame) frame = undefined } + const scheduleReady = (sessionKey: string) => { + if (input.sessionKey() !== sessionKey) return + if (readySession() === sessionKey) return + setReadySession(sessionKey) + } createEffect( on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { + const switched = active !== sessionKey + if (switched) { + active = sessionKey + setReadySession("") + } + + const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey + const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey + + if (staging && !switched && shouldStage && frame !== undefined) return + cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey + + if (shouldStage) setReadySession("") if (!shouldStage) { - setState({ activeSession: "", count: total }) + setState({ + activeSession: "", + completedSession: isWindowed ? sessionKey : state.completedSession, + count: total, + }) + if (total <= 0) { + setReadySession("") + return + } + if (readySession() !== sessionKey) scheduleReady(sessionKey) return } let count = Math.min(total, input.config.init) + if (staging) count = Math.min(total, Math.max(count, state.count)) setState({ activeSession: sessionKey, count }) const step = () => { @@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) { } const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) + startTransition(() => setState("count", count)) if (count >= currentTotal) { setState({ completedSession: sessionKey, activeSession: "" }) frame = undefined + scheduleReady(sessionKey) return } frame = requestAnimationFrame(step) @@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) { const key = input.sessionKey() return state.activeSession === key && state.completedSession !== key }) + const ready = createMemo(() => readySession() === input.sessionKey()) - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } + onCleanup(() => { + cancel() + }) + return { messages: stagedUserMessages, isStaging, ready } } export function MessageTimeline(props: { @@ -196,6 +231,7 @@ export function MessageTimeline(props: { onScrollSpyScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void + onPreserveScrollAnchor: (target: HTMLElement) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -210,14 +246,19 @@ export function MessageTimeline(props: { let touchGesture: number | undefined const params = useParams() - const navigate = useNavigate() - const sdk = useSDK() const sync = useSync() const settings = useSettings() - const dialog = useDialog() const language = useLanguage() - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) + const trigger = (target: EventTarget | null) => { + const next = + target instanceof Element + ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]') + : undefined + if (!(next instanceof HTMLElement)) return + return next + } + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { @@ -230,28 +271,20 @@ export function MessageTimeline(props: { (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", ), ) - const sessionStatus = createMemo(() => { - const id = sessionID() - if (!id) return idle - return sync.data.session_status[id] ?? idle - }) + const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle") const activeMessageID = createMemo(() => { - const parentID = pending()?.parentID - if (parentID) { - const messages = sessionMessages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message && message.role === "user") return message.id + const messages = sessionMessages() + const message = pending() + if (message?.parentID) { + const result = Binary.search(messages, message.parentID, (item) => item.id) + const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID) + if (parent?.role === "user") return parent.id } - const status = sessionStatus() - if (status.type !== "idle") { - const messages = sessionMessages() - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id - } + if (sessionStatus() === "idle") return undefined + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id } - return undefined }) const info = createMemo(() => { @@ -259,9 +292,19 @@ export function MessageTimeline(props: { if (!id) return return sync.session.get(id) }) - const titleValue = createMemo(() => info()?.title) + const titleValue = createMemo(() => { + const title = info()?.title + if (!title) return + if (isDefaultSessionTitle(title)) return language.t("command.session.new") + return title + }) + const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title)) + const headerTitle = createMemo( + () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined), + ) + const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0)) const parentID = createMemo(() => info()?.parentID) - const showHeader = createMemo(() => !!(titleValue() || parentID())) + const showHeader = createMemo(() => !!(headerTitle() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, @@ -269,212 +312,7 @@ export function MessageTimeline(props: { messages: () => props.renderedUserMessages, config: stageCfg, }) - - 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> - ) - } + const rendered = createMemo(() => staging.messages().map((message) => message.id)) return ( <Show @@ -498,6 +336,16 @@ export function MessageTimeline(props: { <Icon name="arrow-down-to-line" /> </button> </div> + <SessionTimelineHeader + centered={props.centered} + showHeader={showHeader} + sessionKey={sessionKey} + sessionID={sessionID} + parentID={parentID} + titleValue={titleValue} + headerTitle={headerTitle} + placeholderTitle={placeholderTitle} + /> <ScrollView viewportRef={props.setScrollRef} onWheel={(e) => { @@ -532,9 +380,18 @@ export function MessageTimeline(props: { touchGesture = undefined }} onPointerDown={(e) => { + const next = trigger(e.target) + if (next) props.onPreserveScrollAnchor(next) + if (e.target !== e.currentTarget) return props.onMarkScrollGesture(e.currentTarget) }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return + const next = trigger(e.target) + if (!next) return + props.onPreserveScrollAnchor(next) + }} onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() @@ -543,131 +400,21 @@ export function MessageTimeline(props: { props.onMarkScrollGesture(e.currentTarget) if (props.isDesktop) props.onScrollSpyScroll() }} - onClick={props.onAutoScrollInteraction} + onClick={(e) => { + props.onAutoScrollInteraction(e) + }} class="relative min-w-0 w-full h-full" style={{ - "--session-title-height": showHeader() ? "40px" : "0px", + "--session-title-height": showHeader() ? "72px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > - <div ref={props.setContentRef} class="min-w-0 w-full"> - <Show when={showHeader()}> - <div - data-session-title - 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> <div + ref={props.setContentRef} role="log" - class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]" + class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]" + style={{ "padding-top": "var(--session-title-height)" }} classList={{ "w-full": true, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, @@ -692,6 +439,15 @@ export function MessageTimeline(props: { </Show> <For each={rendered()}> {(messageID) => { + // Capture at creation time: animate only messages added after the + // timeline finishes its initial backfill staging, plus the first + // turn while a brand new session is still using its default title. + const isNew = + staging.ready() || + (defaultTitle() && + sessionStatus() !== "idle" && + props.renderedUserMessages.length === 1 && + messageID === props.renderedUserMessages[0]?.id) const active = createMemo(() => activeMessageID() === messageID) const queued = createMemo(() => { if (active()) return false @@ -700,7 +456,10 @@ export function MessageTimeline(props: { return false }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), + equals: (a, b) => { + if (a.length !== b.length) return false + return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment) + }, }) const commentCount = createMemo(() => comments().length) return ( @@ -757,7 +516,7 @@ export function MessageTimeline(props: { messageID={messageID} active={active()} queued={queued()} - status={active() ? sessionStatus() : undefined} + animate={isNew || active()} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx new file mode 100644 index 000000000..fcddb38a4 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-header.tsx @@ -0,0 +1,522 @@ +import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { prefersReducedMotion } from "@opencode-ai/ui/hooks" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" +import { showToast } from "@opencode-ai/ui/toast" +import { errorMessage } from "@/pages/layout/helpers" +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" + +export function SessionTimelineHeader(props: { + centered: boolean + showHeader: () => boolean + sessionKey: () => string + sessionID: () => string | undefined + parentID: () => string | undefined + titleValue: () => string | undefined + headerTitle: () => string | undefined + placeholderTitle: () => boolean +}) { + const navigate = useNavigate() + const params = useParams() + const sdk = useSDK() + const sync = useSync() + const dialog = useDialog() + const language = useLanguage() + const reduce = prefersReducedMotion + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + const [headerText, setHeaderText] = createStore({ + session: props.sessionKey(), + value: props.headerTitle(), + prev: undefined as string | undefined, + muted: props.placeholderTitle(), + prevMuted: false, + }) + let headerAnim: AnimationPlaybackControls | undefined + let enterAnim: AnimationPlaybackControls | undefined + let leaveAnim: AnimationPlaybackControls | undefined + let titleRef: HTMLInputElement | undefined + let headerRef: HTMLDivElement | undefined + let enterRef: HTMLSpanElement | undefined + let leaveRef: HTMLSpanElement | undefined + + const clearHeaderAnim = () => { + headerAnim?.stop() + headerAnim = undefined + } + + const animateHeader = () => { + const el = headerRef + if (!el) return + + clearHeaderAnim() + if (!headerText.muted || reduce()) { + el.style.opacity = "1" + return + } + + headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 }) + headerAnim.finished.then(() => { + if (headerRef !== el) return + clearFadeStyles(el) + }) + } + + const clearTitleAnims = () => { + enterAnim?.stop() + enterAnim = undefined + leaveAnim?.stop() + leaveAnim = undefined + } + + const settleTitleEnter = () => { + if (enterRef) clearFadeStyles(enterRef) + } + + const hideLeave = () => { + if (!leaveRef) return + leaveRef.style.opacity = "0" + leaveRef.style.filter = "" + leaveRef.style.transform = "" + } + + const animateEnterSpan = () => { + if (!enterRef) return + if (reduce()) { + settleTitleEnter() + return + } + enterAnim = animate( + enterRef, + { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] }, + FAST_SPRING, + ) + enterAnim.finished.then(() => settleTitleEnter()) + } + + const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ prev: headerText.value, prevMuted: headerText.muted }) + setHeaderText({ value: nextTitle, muted: nextMuted }) + + if (reduce()) { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + settleTitleEnter() + return + } + + if (leaveRef) { + leaveAnim = animate( + leaveRef, + { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] }, + FAST_SPRING, + ) + leaveAnim.finished.then(() => { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + }) + } + + animateEnterSpan() + } + + const fadeInTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) + animateEnterSpan() + } + + const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) + settleTitleEnter() + } + + createEffect( + on(props.showHeader, (show, prev) => { + if (!show) { + clearHeaderAnim() + return + } + if (show === prev) return + animateHeader() + }), + ) + + createEffect( + on( + () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const, + ([nextSession, nextTitle, nextMuted]) => { + if (nextSession !== headerText.session) { + setHeaderText("session", nextSession) + if (nextTitle && nextMuted) { + fadeInTitle(nextTitle, nextMuted) + return + } + snapTitle(nextTitle, nextMuted) + return + } + if (nextTitle === headerText.value && nextMuted === headerText.muted) return + if (!nextTitle) { + snapTitle(undefined, false) + return + } + if (!headerText.value) { + fadeInTitle(nextTitle, nextMuted) + return + } + if (title.saving || title.editing) { + snapTitle(nextTitle, nextMuted) + return + } + crossfadeTitle(nextTitle, nextMuted) + }, + ), + ) + + onCleanup(() => { + clearHeaderAnim() + clearTitleAnims() + }) + + const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed")) + + createEffect( + on( + props.sessionKey, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!props.sessionID()) return + setTitle({ editing: true, draft: props.titleValue() ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const id = props.sessionID() + if (!id) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (props.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((session) => session.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: toastError(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((item) => item.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((item) => item.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: toastError(err), + }) + }) + } + + const deleteSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived) + const index = sessions.findIndex((item) => item.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: toastError(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((item) => !removed.has(item.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = props.parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(input: { sessionID: string }) { + const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new")) + + const handleDelete = async () => { + await deleteSession(input.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.showHeader()}> + <div + data-session-title + ref={(el) => { + headerRef = el + el.style.opacity = "0" + }} + class="pointer-events-none absolute inset-x-0 top-0 z-30" + > + <div + classList={{ + "bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true, + "w-full": true, + "pb-10": true, + "px-4 md:px-5": true, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, + }} + > + <div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2"> + <div class="flex items-center gap-1 min-w-0 flex-1"> + <Show when={props.parentID()}> + <div> + <IconButton + tabIndex={-1} + icon="arrow-left" + variant="ghost" + onClick={navigateParent} + aria-label={language.t("common.goBack")} + /> + </div> + </Show> + <Show when={!!headerText.value || title.editing}> + <Show + when={title.editing} + fallback={ + <h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}> + <span class="grid min-w-0" style={{ overflow: "clip" }}> + <span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate"> + <span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span> + </span> + <span + ref={leaveRef} + class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none" + style={{ opacity: "0" }} + > + <span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span> + </span> + </span> + </h1> + } + > + <InlineInput + ref={(el) => { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 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={props.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> + </div> + </Show> + ) +} diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 20e88a3ea..278a1ba6e 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,6 +1,5 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" -import { useLocation, useNavigate } from "@solidjs/router" -import { createEffect, createMemo, onMount } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" export { messageIdFromHash } from "./message-id-from-hash" @@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: { setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - autoScroll: { pause: () => void; forceScrollToBottom: () => void } + autoScroll: { pause: () => void; snapToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void @@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: { const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) let pendingKey = "" - const location = useLocation() - const navigate = useNavigate() - const clearMessageHash = () => { - if (!location.hash) return - navigate(location.pathname + location.search, { replace: true }) + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.pathname + window.location.search) } const updateHash = (id: string) => { - navigate(location.pathname + location.search + `#${input.anchor(id)}`, { - replace: true, - }) + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`) } const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: { const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() - const sticky = root.querySelector("[data-session-title]") - const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 - const top = Math.max(0, a.top - b.top + root.scrollTop - inset) + const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) + const inset = Number.isNaN(title) ? 0 : title + // With column-reverse, scrollTop is negative — don't clamp to 0 + const top = a.top - b.top + root.scrollTop - inset root.scrollTo({ top, behavior }) return true } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - console.log({ message, behavior }) if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 @@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: { } const applyHash = (behavior: ScrollBehavior) => { - const hash = location.hash.slice(1) + const hash = window.location.hash.slice(1) if (!hash) { - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) return @@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: { return } - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) } + onMount(() => { + if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual" + } + + const handler = () => { + if (!input.sessionID() || !input.messagesReady()) return + requestAnimationFrame(() => applyHash("auto")) + } + + window.addEventListener("hashchange", handler) + onCleanup(() => window.removeEventListener("hashchange", handler)) + }) + createEffect(() => { - location.hash if (!input.sessionID() || !input.messagesReady()) return requestAnimationFrame(() => applyHash("auto")) }) @@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: { } } - if (!targetId) targetId = messageIdFromHash(location.hash) if (!targetId) return if (input.currentMessageId() === targetId) return @@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: { requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) - onMount(() => { - if (typeof window !== "undefined" && "scrollRestoration" in window.history) { - window.history.scrollRestoration = "manual" - } - }) - return { clearMessageHash, scrollToMessage, |
