summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-07 06:25:22 -0500
committerGitHub <[email protected]>2026-03-07 05:25:22 -0600
commitbbd0f3a25283b6f9567a04e79d7f6972950ab0a6 (patch)
tree56cb41ff9c67749c6fa894fef32bb14cefd73db3 /packages/app/src
parentb7e208b4f1e6641a1cbb1e13f59789c7b7f4c60a (diff)
downloadopencode-bbd0f3a25283b6f9567a04e79d7f6972950ab0a6.tar.gz
opencode-bbd0f3a25283b6f9567a04e79d7f6972950ab0a6.zip
STUPID SEXY TIMELINE (#16420)
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/pages/session.tsx21
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx501
-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
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,