|
|
@@ -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()}
|