summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-09 07:36:39 -0500
committerGitHub <[email protected]>2026-03-09 07:36:39 -0500
commitc71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e (patch)
treea30482cedb38dc24cad70e24ad717817065620d6 /packages/app/src
parentf27ef595f65aa719be3f8d08665d683e95083ed3 (diff)
downloadopencode-c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e.tar.gz
opencode-c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e.zip
revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745)
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/pages/session.tsx268
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx2
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx4
-rw-r--r--packages/app/src/pages/session/history-window.test.ts35
-rw-r--r--packages/app/src/pages/session/history-window.ts273
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx506
-rw-r--r--packages/app/src/pages/session/session-timeline-header.tsx522
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.ts52
8 files changed, 618 insertions, 1044 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 90769a28a..3f5da0c94 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -41,12 +41,220 @@ import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
-import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
+const emptyUserMessages: UserMessage[] = []
+
+type SessionHistoryWindowInput = {
+ sessionID: () => string | undefined
+ messagesReady: () => boolean
+ visibleUserMessages: () => UserMessage[]
+ historyMore: () => boolean
+ historyLoading: () => boolean
+ loadMore: (sessionID: string) => Promise<void>
+ userScrolled: () => boolean
+ scroller: () => HTMLDivElement | undefined
+}
+
+/**
+ * Maintains the rendered history window for a session timeline.
+ *
+ * It keeps initial paint bounded to recent turns, reveals cached turns in
+ * small batches while scrolling upward, and prefetches older history near top.
+ */
+function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
+ const turnInit = 10
+ const turnBatch = 8
+ const turnScrollThreshold = 200
+ const turnPrefetchBuffer = 16
+ const prefetchCooldownMs = 400
+ const prefetchNoGrowthLimit = 2
+
+ const [state, setState] = createStore({
+ turnID: undefined as string | undefined,
+ turnStart: 0,
+ prefetchUntil: 0,
+ prefetchNoGrowth: 0,
+ })
+
+ const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
+
+ const turnStart = createMemo(() => {
+ const id = input.sessionID()
+ const len = input.visibleUserMessages().length
+ if (!id || len <= 0) return 0
+ if (state.turnID !== id) return initialTurnStart(len)
+ if (state.turnStart <= 0) return 0
+ if (state.turnStart >= len) return initialTurnStart(len)
+ return state.turnStart
+ })
+
+ const setTurnStart = (start: number) => {
+ const id = input.sessionID()
+ const next = start > 0 ? start : 0
+ if (!id) {
+ setState({ turnID: undefined, turnStart: next })
+ return
+ }
+ setState({ turnID: id, turnStart: next })
+ }
+
+ const renderedUserMessages = createMemo(
+ () => {
+ const msgs = input.visibleUserMessages()
+ const start = turnStart()
+ if (start <= 0) return msgs
+ return msgs.slice(start)
+ },
+ emptyUserMessages,
+ {
+ equals: same,
+ },
+ )
+
+ const preserveScroll = (fn: () => void) => {
+ const el = input.scroller()
+ if (!el) {
+ fn()
+ return
+ }
+ const beforeTop = el.scrollTop
+ const beforeHeight = el.scrollHeight
+ fn()
+ requestAnimationFrame(() => {
+ const delta = el.scrollHeight - beforeHeight
+ if (!delta) return
+ el.scrollTop = beforeTop + delta
+ })
+ }
+
+ const backfillTurns = () => {
+ const start = turnStart()
+ if (start <= 0) return
+
+ const next = start - turnBatch
+ const nextStart = next > 0 ? next : 0
+
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
+ const loadAndReveal = async () => {
+ const id = input.sessionID()
+ if (!id) return
+
+ const start = turnStart()
+ const beforeVisible = input.visibleUserMessages().length
+
+ if (start > 0) setTurnStart(0)
+
+ if (!input.historyMore() || input.historyLoading()) return
+
+ await input.loadMore(id)
+ if (input.sessionID() !== id) return
+
+ const afterVisible = input.visibleUserMessages().length
+ const growth = afterVisible - beforeVisible
+ if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
+ if (growth <= 0) return
+ if (turnStart() !== 0) return
+
+ const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
+ const nextStart = Math.max(0, afterVisible - target)
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ /** Scroll/prefetch path: fetch older history from server. */
+ const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
+ const id = input.sessionID()
+ if (!id) return
+ if (!input.historyMore() || input.historyLoading()) return
+
+ if (opts?.prefetch) {
+ const now = Date.now()
+ if (state.prefetchUntil > now) return
+ if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
+ setState("prefetchUntil", now + prefetchCooldownMs)
+ }
+
+ const start = turnStart()
+ const beforeVisible = input.visibleUserMessages().length
+ const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
+
+ await input.loadMore(id)
+ if (input.sessionID() !== id) return
+
+ const afterVisible = input.visibleUserMessages().length
+ const growth = afterVisible - beforeVisible
+
+ if (opts?.prefetch) {
+ setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
+ } else if (growth > 0 && state.prefetchNoGrowth) {
+ setState("prefetchNoGrowth", 0)
+ }
+
+ if (growth <= 0) return
+ if (turnStart() !== start) return
+
+ const reveal = !opts?.prefetch
+ const currentRendered = renderedUserMessages().length
+ const base = Math.max(beforeRendered, currentRendered)
+ const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
+ const nextStart = Math.max(0, afterVisible - target)
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ const onScrollerScroll = () => {
+ if (!input.userScrolled()) return
+ const el = input.scroller()
+ if (!el) return
+ if (el.scrollTop >= turnScrollThreshold) return
+
+ const start = turnStart()
+ if (start > 0) {
+ if (start <= turnPrefetchBuffer) {
+ void fetchOlderMessages({ prefetch: true })
+ }
+ backfillTurns()
+ return
+ }
+
+ void fetchOlderMessages()
+ }
+
+ createEffect(
+ on(
+ input.sessionID,
+ () => {
+ setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => [input.sessionID(), input.messagesReady()] as const,
+ ([id, ready]) => {
+ if (!id || !ready) return
+ setTurnStart(initialTurnStart(input.visibleUserMessages().length))
+ },
+ { defer: true },
+ ),
+ )
+
+ return {
+ turnStart,
+ setTurnStart,
+ renderedUserMessages,
+ loadAndReveal,
+ onScrollerScroll,
+ }
+}
+
export default function Page() {
const globalSync = useGlobalSync()
const layout = useLayout()
@@ -886,7 +1094,6 @@ export default function Page() {
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
- let historyFillFrame: number | undefined
const scrollSpy = createScrollSpy({
onActive: (id) => {
if (id === store.messageId) return
@@ -897,7 +1104,7 @@ export default function Page() {
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
- const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
+ const bottom = !overflow || el.scrollTop >= max - 2
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -920,7 +1127,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
- autoScroll.smoothScrollToBottom()
+ autoScroll.forceScrollToBottom()
clearMessageHash()
const el = scroller
@@ -956,9 +1163,7 @@ export default function Page() {
scroller = el
autoScroll.scrollRef(el)
scrollSpy.setContainer(el)
- if (!el) return
- scheduleScrollState(el)
- scheduleHistoryFill()
+ if (el) scheduleScrollState(el)
}
createResizeObserver(
@@ -967,7 +1172,6 @@ export default function Page() {
const el = scroller
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
- scheduleHistoryFill()
},
)
@@ -982,45 +1186,6 @@ export default function Page() {
scroller: () => scroller,
})
- const scheduleHistoryFill = () => {
- if (historyFillFrame !== undefined) return
-
- historyFillFrame = requestAnimationFrame(() => {
- historyFillFrame = undefined
-
- if (!params.id || !messagesReady()) return
- if (autoScroll.userScrolled() || historyLoading()) return
-
- const el = scroller
- if (!el) return
- if (el.scrollHeight > el.clientHeight + 1) return
- if (historyWindow.turnStart() <= 0 && !historyMore()) return
-
- void historyWindow.loadAndReveal()
- })
- }
-
- createEffect(
- on(
- () =>
- [
- params.id,
- messagesReady(),
- historyWindow.turnStart(),
- historyMore(),
- historyLoading(),
- autoScroll.userScrolled(),
- visibleUserMessages().length,
- ] as const,
- ([id, ready, start, more, loading, scrolled]) => {
- if (!id || !ready || loading || scrolled) return
- if (start <= 0 && !more) return
- scheduleHistoryFill()
- },
- { defer: true },
- ),
- )
-
createResizeObserver(
() => promptDock,
({ height }) => {
@@ -1030,15 +1195,16 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
- const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
+ const stick = el
+ ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
+ : false
dockHeight = next
- if (stick) autoScroll.smoothScrollToBottom()
+ if (stick) autoScroll.forceScrollToBottom()
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
- scheduleHistoryFill()
},
)
@@ -1068,7 +1234,6 @@ export default function Page() {
document.removeEventListener("keydown", handleKeyDown)
scrollSpy.destroy()
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
- if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
})
return (
@@ -1122,7 +1287,6 @@ 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/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
index 18a02993b..93ea3d465 100644
--- a/packages/app/src/pages/session/composer/session-composer-region.tsx
+++ b/packages/app/src/pages/session/composer/session-composer-region.tsx
@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
<div
classList={{
"w-full px-3 pointer-events-auto": true,
- "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
+ "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index 07df4305f..77643789d 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
)
return (
- <Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
+ <Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
- class="h-full min-h-0 flex-1"
+ class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
diff --git a/packages/app/src/pages/session/history-window.test.ts b/packages/app/src/pages/session/history-window.test.ts
deleted file mode 100644
index 4a9b894e2..000000000
--- a/packages/app/src/pages/session/history-window.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { historyLoadMode, historyRevealTop } from "./history-window"
-
-describe("historyLoadMode", () => {
- test("reveals cached turns before fetching", () => {
- expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal")
- })
-
- test("fetches older history when cache is already revealed", () => {
- expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch")
- })
-
- test("does nothing while history is unavailable or loading", () => {
- expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop")
- expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop")
- })
-})
-
-describe("historyRevealTop", () => {
- test("pins the viewport to the top when older turns were revealed there", () => {
- expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
- -1400,
- )
- })
-
- test("keeps the latest turns pinned when the viewport was underfilled", () => {
- expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0)
- })
-
- test("keeps the current anchor when the user was not at the top", () => {
- expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
- -200,
- )
- })
-})
diff --git a/packages/app/src/pages/session/history-window.ts b/packages/app/src/pages/session/history-window.ts
deleted file mode 100644
index e3ef20f13..000000000
--- a/packages/app/src/pages/session/history-window.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { createEffect, createMemo, on } from "solid-js"
-import { createStore } from "solid-js/store"
-import { same } from "@/utils/same"
-
-export const emptyUserMessages: UserMessage[] = []
-
-export type SessionHistoryWindowInput = {
- sessionID: () => string | undefined
- messagesReady: () => boolean
- visibleUserMessages: () => UserMessage[]
- historyMore: () => boolean
- historyLoading: () => boolean
- loadMore: (sessionID: string) => Promise<void>
- userScrolled: () => boolean
- scroller: () => HTMLDivElement | undefined
-}
-
-type Snap = {
- top: number
- height: number
- gap: number
- max: number
-}
-
-export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => {
- if (input.start > 0) return "reveal"
- if (!input.more || input.loading) return "noop"
- return "fetch"
-}
-
-export const historyRevealTop = (
- mark: { top: number; height: number; gap: number; max: number },
- next: { clientHeight: number; height: number },
- threshold = 16,
-) => {
- const delta = next.height - mark.height
- if (delta <= 0) return mark.top
- if (mark.max <= 0) return mark.top
- if (mark.gap > threshold) return mark.top
-
- const max = next.height - next.clientHeight
- if (max <= 0) return 0
- return Math.max(-max, Math.min(0, mark.top - delta))
-}
-
-const snap = (el: HTMLDivElement | undefined): Snap | undefined => {
- if (!el) return
- const max = el.scrollHeight - el.clientHeight
- return {
- top: el.scrollTop,
- height: el.scrollHeight,
- gap: max + el.scrollTop,
- max,
- }
-}
-
-const clamp = (el: HTMLDivElement, top: number) => {
- const max = el.scrollHeight - el.clientHeight
- if (max <= 0) return 0
- return Math.max(-max, Math.min(0, top))
-}
-
-const revealThreshold = 16
-
-const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => {
- const el = input.scroller()
- if (!el || !mark) return
- el.scrollTop = clamp(
- el,
- historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold),
- )
-}
-
-const preserve = (input: SessionHistoryWindowInput, fn: () => void) => {
- const el = input.scroller()
- if (!el) {
- fn()
- return
- }
- const top = el.scrollTop
- fn()
- el.scrollTop = top
-}
-
-/**
- * Maintains the rendered history window for a session timeline.
- *
- * It keeps initial paint bounded to recent turns, reveals cached turns in
- * small batches while scrolling upward, and prefetches older history near top.
- */
-export function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
- const turnInit = 10
- const turnBatch = 8
- const turnScrollThreshold = 200
- const turnPrefetchBuffer = 16
- const prefetchCooldownMs = 400
- const prefetchNoGrowthLimit = 2
-
- const [state, setState] = createStore({
- turnID: undefined as string | undefined,
- turnStart: 0,
- prefetchUntil: 0,
- prefetchNoGrowth: 0,
- })
-
- const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
-
- const turnStart = createMemo(() => {
- const id = input.sessionID()
- const len = input.visibleUserMessages().length
- if (!id || len <= 0) return 0
- if (state.turnID !== id) return initialTurnStart(len)
- if (state.turnStart <= 0) return 0
- if (state.turnStart >= len) return initialTurnStart(len)
- return state.turnStart
- })
-
- const setTurnStart = (start: number) => {
- const id = input.sessionID()
- const next = start > 0 ? start : 0
- if (!id) {
- setState({ turnID: undefined, turnStart: next })
- return
- }
- setState({ turnID: id, turnStart: next })
- }
-
- const renderedUserMessages = createMemo(
- () => {
- const msgs = input.visibleUserMessages()
- const start = turnStart()
- if (start <= 0) return msgs
- return msgs.slice(start)
- },
- emptyUserMessages,
- {
- equals: same,
- },
- )
-
- const backfillTurns = () => {
- const start = turnStart()
- if (start <= 0) return
-
- const next = start - turnBatch
- const nextStart = next > 0 ? next : 0
-
- preserve(input, () => setTurnStart(nextStart))
- }
-
- /** Button path: reveal cached turns first, then fetch older history. */
- const loadAndReveal = async () => {
- const id = input.sessionID()
- if (!id) return
-
- const start = turnStart()
- const mode = historyLoadMode({
- start,
- more: input.historyMore(),
- loading: input.historyLoading(),
- })
-
- if (mode === "reveal") {
- const mark = snap(input.scroller())
- setTurnStart(0)
- reveal(input, mark)
- return
- }
-
- if (mode === "noop") return
-
- const beforeVisible = input.visibleUserMessages().length
- const mark = snap(input.scroller())
-
- await input.loadMore(id)
- if (input.sessionID() !== id) return
-
- const afterVisible = input.visibleUserMessages().length
- const growth = afterVisible - beforeVisible
- if (growth <= 0) return
- if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
-
- reveal(input, mark)
- }
-
- /** Scroll/prefetch path: fetch older history from server. */
- const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
- const id = input.sessionID()
- if (!id) return
- if (!input.historyMore() || input.historyLoading()) return
-
- if (opts?.prefetch) {
- const now = Date.now()
- if (state.prefetchUntil > now) return
- if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
- setState("prefetchUntil", now + prefetchCooldownMs)
- }
-
- const start = turnStart()
- const beforeVisible = input.visibleUserMessages().length
- const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
-
- await input.loadMore(id)
- if (input.sessionID() !== id) return
-
- const afterVisible = input.visibleUserMessages().length
- const growth = afterVisible - beforeVisible
-
- if (opts?.prefetch) {
- setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
- } else if (growth > 0 && state.prefetchNoGrowth) {
- setState("prefetchNoGrowth", 0)
- }
-
- if (growth <= 0) return
- if (turnStart() !== start) return
-
- const revealMore = !opts?.prefetch
- const currentRendered = renderedUserMessages().length
- const base = Math.max(beforeRendered, currentRendered)
- const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base
- const nextStart = Math.max(0, afterVisible - target)
- preserve(input, () => setTurnStart(nextStart))
- }
-
- const onScrollerScroll = () => {
- if (!input.userScrolled()) return
- const el = input.scroller()
- if (!el) return
- if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
-
- const start = turnStart()
- if (start > 0) {
- if (start <= turnPrefetchBuffer) {
- void fetchOlderMessages({ prefetch: true })
- }
- backfillTurns()
- return
- }
-
- void fetchOlderMessages()
- }
-
- createEffect(
- on(
- input.sessionID,
- () => {
- setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => [input.sessionID(), input.messagesReady()] as const,
- ([id, ready]) => {
- if (!id || !ready) return
- setTurnStart(initialTurnStart(input.visibleUserMessages().length))
- },
- { defer: true },
- ),
- )
-
- return {
- turnStart,
- setTurnStart,
- renderedUserMessages,
- loadAndReveal,
- onScrollerScroll,
- }
-}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index e93ca11a3..ce6a01378 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,31 +1,27 @@
-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 { 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 { 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
@@ -37,9 +33,7 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
-
-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 idle = { type: "idle" as const }
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
@@ -116,8 +110,6 @@ function createTimelineStaging(input: TimelineStageInput) {
completedSession: "",
count: 0,
})
- const [readySession, setReadySession] = createSignal("")
- let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
@@ -142,46 +134,23 @@ 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()
-
- if (shouldStage) setReadySession("")
+ const shouldStage =
+ isWindowed &&
+ total > input.config.init &&
+ state.completedSession !== sessionKey &&
+ state.activeSession !== sessionKey
if (!shouldStage) {
- setState({
- activeSession: "",
- completedSession: isWindowed ? sessionKey : state.completedSession,
- count: total,
- })
- if (total <= 0) {
- setReadySession("")
- return
- }
- if (readySession() !== sessionKey) scheduleReady(sessionKey)
+ setState({ activeSession: "", count: total })
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 = () => {
@@ -191,11 +160,10 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
- startTransition(() => setState("count", count))
+ setState("count", count)
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
- scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
@@ -209,12 +177,9 @@ 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, ready }
+ onCleanup(cancel)
+ return { messages: stagedUserMessages, isStaging }
}
export function MessageTimeline(props: {
@@ -231,7 +196,6 @@ export function MessageTimeline(props: {
onScrollSpyScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
- onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
@@ -246,19 +210,14 @@ 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 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 rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -271,20 +230,28 @@ export function MessageTimeline(props: {
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
- const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
+ const sessionStatus = createMemo(() => {
+ const id = sessionID()
+ if (!id) return idle
+ return sync.data.session_status[id] ?? idle
+ })
const activeMessageID = createMemo(() => {
- 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 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
}
- if (sessionStatus() === "idle") return undefined
- for (let i = messages.length - 1; i >= 0; i--) {
- if (messages[i].role === "user") return messages[i].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
+ }
}
+
return undefined
})
const info = createMemo(() => {
@@ -292,19 +259,9 @@ export function MessageTimeline(props: {
if (!id) return
return sync.session.get(id)
})
- 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 titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
- const showHeader = createMemo(() => !!(headerTitle() || parentID()))
+ const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
@@ -312,7 +269,212 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
- const rendered = createMemo(() => staging.messages().map((message) => message.id))
+
+ 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
@@ -336,18 +498,7 @@ 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
- reverse
viewportRef={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
@@ -381,18 +532,9 @@ 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()
@@ -401,24 +543,134 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
- onClick={(e) => {
- props.onAutoScrollInteraction(e)
- }}
+ onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{
- "--session-title-height": showHeader() ? "72px" : "0px",
+ "--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
- <div>
+ <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
- ref={props.setContentRef}
role="log"
- class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
- style={{ "padding-top": "var(--session-title-height)" }}
+ class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
- "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
+ "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"mt-0.5": props.centered,
"mt-0": !props.centered,
}}
@@ -440,15 +692,6 @@ 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
@@ -457,10 +700,7 @@ export function MessageTimeline(props: {
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
- 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)
- },
+ equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
})
const commentCount = createMemo(() => comments().length)
return (
@@ -473,7 +713,7 @@ export function MessageTimeline(props: {
}}
classList={{
"min-w-0 w-full max-w-full": true,
- "md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
+ "md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={commentCount() > 0}>
@@ -517,7 +757,7 @@ export function MessageTimeline(props: {
messageID={messageID}
active={active()}
queued={queued()}
- animate={isNew || active()}
+ status={active() ? sessionStatus() : undefined}
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
deleted file mode 100644
index 32412f0a7..000000000
--- a/packages/app/src/pages/session/session-timeline-header.tsx
+++ /dev/null
@@ -1,522 +0,0 @@
-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 { useReducedMotion } from "@opencode-ai/ui/hooks"
-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 { 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 = useReducedMotion()
-
- 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-[500px] md:mx-auto 2xl:max-w-[700px]": 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 278a1ba6e..20e88a3ea 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -1,5 +1,6 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
+import { useLocation, useNavigate } from "@solidjs/router"
+import { createEffect, createMemo, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
export { messageIdFromHash } from "./message-id-from-hash"
@@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
- autoScroll: { pause: () => void; snapToBottom: () => void }
+ autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -26,13 +27,18 @@ 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 (!window.location.hash) return
- window.history.replaceState(null, "", window.location.pathname + window.location.search)
+ if (!location.hash) return
+ navigate(location.pathname + location.search, { replace: true })
}
const updateHash = (id: string) => {
- window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
+ navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
+ replace: true,
+ })
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -41,15 +47,15 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
- 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
+ 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)
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
@@ -97,9 +103,9 @@ export const useSessionHashScroll = (input: {
}
const applyHash = (behavior: ScrollBehavior) => {
- const hash = window.location.hash.slice(1)
+ const hash = location.hash.slice(1)
if (!hash) {
- input.autoScroll.snapToBottom()
+ input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -123,26 +129,13 @@ export const useSessionHashScroll = (input: {
return
}
- input.autoScroll.snapToBottom()
+ input.autoScroll.forceScrollToBottom()
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"))
})
@@ -166,6 +159,7 @@ export const useSessionHashScroll = (input: {
}
}
+ if (!targetId) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -177,6 +171,12 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
+ onMount(() => {
+ if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
+ window.history.scrollRestoration = "manual"
+ }
+ })
+
return {
clearMessageHash,
scrollToMessage,