|
|
@@ -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,7 +336,18 @@ 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
|
|
|
@@ -532,9 +381,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,134 +401,24 @@ 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()} keyed>
|
|
|
- {(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,
|
|
|
+ "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
|
|
|
"mt-0.5": props.centered,
|
|
|
"mt-0": !props.centered,
|
|
|
}}
|
|
|
@@ -692,6 +440,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 +457,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 (
|
|
|
@@ -713,7 +473,7 @@ export function MessageTimeline(props: {
|
|
|
}}
|
|
|
classList={{
|
|
|
"min-w-0 w-full max-w-full": true,
|
|
|
- "md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
|
|
+ "md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
|
|
|
}}
|
|
|
>
|
|
|
<Show when={commentCount() > 0}>
|
|
|
@@ -757,7 +517,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()}
|