|
|
@@ -1,8 +1,36 @@
|
|
|
-import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
|
|
|
+import {
|
|
|
+ createEffect,
|
|
|
+ createMemo,
|
|
|
+ createSignal,
|
|
|
+ For,
|
|
|
+ Index,
|
|
|
+ Match,
|
|
|
+ Switch,
|
|
|
+ on,
|
|
|
+ onCleanup,
|
|
|
+ Show,
|
|
|
+ mapArray,
|
|
|
+ untrack,
|
|
|
+ type Accessor,
|
|
|
+ type JSX,
|
|
|
+} from "solid-js"
|
|
|
import { createStore, produce } from "solid-js/store"
|
|
|
+import { Dynamic } from "solid-js/web"
|
|
|
import { useNavigate } from "@solidjs/router"
|
|
|
import { useMutation } from "@tanstack/solid-query"
|
|
|
+import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
|
|
+import { Accordion } from "@opencode-ai/ui/accordion"
|
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
|
+import { Card } from "@opencode-ai/ui/card"
|
|
|
+import {
|
|
|
+ ContextToolGroup,
|
|
|
+ Message,
|
|
|
+ MessageDivider,
|
|
|
+ Part as MessagePart,
|
|
|
+ partDefaultOpen,
|
|
|
+ type UserActions,
|
|
|
+} from "@opencode-ai/ui/message-part"
|
|
|
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
|
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
|
import { Icon } from "@opencode-ai/ui/icon"
|
|
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
|
@@ -10,14 +38,25 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
|
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
|
-import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
|
|
+import { SessionRetry } from "@opencode-ai/ui/session-retry"
|
|
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
|
|
+import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
|
|
import { TextField } from "@opencode-ai/ui/text-field"
|
|
|
-import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
|
|
+import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
|
|
+import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
|
|
+import type {
|
|
|
+ AssistantMessage,
|
|
|
+ Message as MessageType,
|
|
|
+ Part as PartType,
|
|
|
+ ToolPart,
|
|
|
+ UserMessage,
|
|
|
+} from "@opencode-ai/sdk/v2"
|
|
|
import { showToast } from "@opencode-ai/ui/toast"
|
|
|
import { Binary } from "@opencode-ai/core/util/binary"
|
|
|
-import { getFilename } from "@opencode-ai/core/util/path"
|
|
|
+import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
|
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
|
|
+import { normalize } from "@opencode-ai/ui/session-diff"
|
|
|
+import { useFileComponent } from "@opencode-ai/ui/context/file"
|
|
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
|
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
|
@@ -31,45 +70,54 @@ import { useSDK } from "@/context/sdk"
|
|
|
import { useSync } from "@/context/sync"
|
|
|
import { messageAgentColor } from "@/utils/agent"
|
|
|
import { sessionTitle } from "@/utils/session-title"
|
|
|
-import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
|
|
import { makeTimer } from "@solid-primitives/timer"
|
|
|
-
|
|
|
-type MessageComment = {
|
|
|
- path: string
|
|
|
- comment: string
|
|
|
- selection?: {
|
|
|
- startLine: number
|
|
|
- endLine: number
|
|
|
- }
|
|
|
-}
|
|
|
+import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data"
|
|
|
|
|
|
const emptyMessages: MessageType[] = []
|
|
|
+const emptyParts: PartType[] = []
|
|
|
+const emptyTools: ToolPart[] = []
|
|
|
+const emptyAssistantMessages: AssistantMessage[] = []
|
|
|
const idle = { type: "idle" as const }
|
|
|
-type UserActions = {
|
|
|
- fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
|
|
- revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
|
|
+
|
|
|
+type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "BottomSpacer" }>
|
|
|
+type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
|
|
|
+
|
|
|
+function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
|
|
|
+ if (a === b) return true
|
|
|
+ if (!a || !b) return false
|
|
|
+ if (a.length !== b.length) return false
|
|
|
+ return a.every((key, index) => key === b[index])
|
|
|
}
|
|
|
|
|
|
-const messageComments = (parts: Part[]): MessageComment[] =>
|
|
|
- parts.flatMap((part) => {
|
|
|
- if (part.type !== "text" || !(part as TextPart).synthetic) return []
|
|
|
- const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
|
|
|
- if (!next) return []
|
|
|
- return [
|
|
|
- {
|
|
|
- path: next.path,
|
|
|
- comment: next.comment,
|
|
|
- selection: next.selection
|
|
|
- ? {
|
|
|
- startLine: next.selection.startLine,
|
|
|
- endLine: next.selection.endLine,
|
|
|
- }
|
|
|
- : undefined,
|
|
|
- },
|
|
|
- ]
|
|
|
+const timelineCacheLimit = 16
|
|
|
+const timelineFallbackItemSize = 60
|
|
|
+const timelineCache = new Map<string, { keys: readonly string[]; cache: VirtualizerHandle["cache"] }>()
|
|
|
+
|
|
|
+function readTimelineCache(id: string, keys: readonly string[]) {
|
|
|
+ const entry = timelineCache.get(id)
|
|
|
+ if (!entry) return
|
|
|
+ if (sameKeys(entry.keys, keys)) return entry.cache
|
|
|
+ timelineCache.delete(id)
|
|
|
+}
|
|
|
+
|
|
|
+function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) {
|
|
|
+ if (!handle || keys.length === 0) return
|
|
|
+ timelineCache.delete(id)
|
|
|
+ timelineCache.set(id, { keys: keys.slice(), cache: handle.cache })
|
|
|
+ while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!)
|
|
|
+}
|
|
|
+
|
|
|
+function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
|
|
+ if (!previous?.length) return rows
|
|
|
+ const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
|
|
+ return rows.map((row) => {
|
|
|
+ const existing = byKey.get(TimelineRow.key(row))
|
|
|
+ if (!existing) return row
|
|
|
+ return TimelineRow.equals(existing, row) ? existing : row
|
|
|
})
|
|
|
+}
|
|
|
|
|
|
-const taskDescription = (part: Part, sessionID: string) => {
|
|
|
+const taskDescription = (part: PartType, sessionID: string) => {
|
|
|
if (part.type !== "tool" || part.tool !== "task") return
|
|
|
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
|
|
if (metadata?.sessionId !== sessionID) return
|
|
|
@@ -110,106 +158,114 @@ const markBoundaryGesture = (input: {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-type StageConfig = {
|
|
|
- init: number
|
|
|
- batch: number
|
|
|
-}
|
|
|
+function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean }) {
|
|
|
+ const language = useLanguage()
|
|
|
|
|
|
-type TimelineStageInput = {
|
|
|
- sessionKey: () => string
|
|
|
- turnStart: () => number
|
|
|
- messages: () => UserMessage[]
|
|
|
- config: StageConfig
|
|
|
+ return (
|
|
|
+ <div data-slot="session-turn-thinking">
|
|
|
+ <TextShimmer text={language.t("ui.sessionTurn.status.thinking")} />
|
|
|
+ <Show when={!props.showReasoningSummaries}>
|
|
|
+ <TextReveal text={props.reasoningHeading} class="session-turn-thinking-heading" travel={25} duration={700} />
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * Defer-mounts small timeline windows so revealing older turns does not
|
|
|
- * block first paint with a large DOM mount.
|
|
|
- *
|
|
|
- * Once staging completes for a session it never re-stages — backfill and
|
|
|
- * new messages render immediately.
|
|
|
- */
|
|
|
-function createTimelineStaging(input: TimelineStageInput) {
|
|
|
+function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
|
|
+ const language = useLanguage()
|
|
|
+ const maxFiles = 10
|
|
|
const [state, setState] = createStore({
|
|
|
- activeSession: "",
|
|
|
- completedSession: "",
|
|
|
- count: 0,
|
|
|
- })
|
|
|
-
|
|
|
- const stagedCount = createMemo(() => {
|
|
|
- const total = input.messages().length
|
|
|
- if (input.turnStart() <= 0) return total
|
|
|
- if (state.completedSession === input.sessionKey()) return total
|
|
|
- const init = Math.min(total, input.config.init)
|
|
|
- if (state.count <= init) return init
|
|
|
- if (state.count >= total) return total
|
|
|
- return state.count
|
|
|
+ showAll: false,
|
|
|
+ expanded: [] as string[],
|
|
|
})
|
|
|
+ const showAll = () => state.showAll
|
|
|
+ const expanded = () => state.expanded
|
|
|
+ const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles))
|
|
|
+ const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles)))
|
|
|
|
|
|
- const stagedUserMessages = createMemo(() => {
|
|
|
- const list = input.messages()
|
|
|
- const count = stagedCount()
|
|
|
- if (count >= list.length) return list
|
|
|
- return list.slice(Math.max(0, list.length - count))
|
|
|
- })
|
|
|
-
|
|
|
- let frame: number | undefined
|
|
|
- const cancel = () => {
|
|
|
- if (frame === undefined) return
|
|
|
- cancelAnimationFrame(frame)
|
|
|
- frame = undefined
|
|
|
- }
|
|
|
-
|
|
|
- createEffect(
|
|
|
- on(
|
|
|
- () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
|
|
|
- ([sessionKey, isWindowed, total]) => {
|
|
|
- cancel()
|
|
|
- const shouldStage =
|
|
|
- isWindowed &&
|
|
|
- total > input.config.init &&
|
|
|
- state.completedSession !== sessionKey &&
|
|
|
- state.activeSession !== sessionKey
|
|
|
- if (!shouldStage) {
|
|
|
- setState({ activeSession: "", count: total })
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let count = Math.min(total, input.config.init)
|
|
|
- setState({ activeSession: sessionKey, count })
|
|
|
-
|
|
|
- const step = () => {
|
|
|
- if (input.sessionKey() !== sessionKey) {
|
|
|
- frame = undefined
|
|
|
- return
|
|
|
- }
|
|
|
- const currentTotal = input.messages().length
|
|
|
- count = Math.min(currentTotal, count + input.config.batch)
|
|
|
- setState("count", count)
|
|
|
- if (count >= currentTotal) {
|
|
|
- setState({ completedSession: sessionKey, activeSession: "" })
|
|
|
- frame = undefined
|
|
|
- return
|
|
|
- }
|
|
|
- frame = requestAnimationFrame(step)
|
|
|
- }
|
|
|
- frame = requestAnimationFrame(step)
|
|
|
- },
|
|
|
- ),
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ data-slot="session-turn-diffs"
|
|
|
+ data-component="session-turn-diffs-group"
|
|
|
+ data-show-all={showAll() || undefined}
|
|
|
+ >
|
|
|
+ <div data-slot="session-turn-diffs-header">
|
|
|
+ <span data-slot="session-turn-diffs-label">
|
|
|
+ {props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")}{" "}
|
|
|
+ {language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
|
|
+ </span>
|
|
|
+ <DiffChanges changes={props.diffs} />
|
|
|
+ <Show when={overflow() > 0}>
|
|
|
+ <span data-slot="session-turn-diffs-toggle" onClick={() => setState("showAll", !showAll())}>
|
|
|
+ {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
|
|
+ </span>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ <div data-component="session-turn-diffs-content">
|
|
|
+ <Accordion
|
|
|
+ multiple
|
|
|
+ style={{ "--sticky-accordion-offset": "44px" }}
|
|
|
+ value={expanded()}
|
|
|
+ onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
|
|
|
+ >
|
|
|
+ <For each={visible()}>
|
|
|
+ {(diff) => {
|
|
|
+ const opened = createMemo(() => expanded().includes(diff.file))
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Accordion.Item value={diff.file}>
|
|
|
+ <StickyAccordionHeader>
|
|
|
+ <Accordion.Trigger>
|
|
|
+ <div data-slot="session-turn-diff-trigger">
|
|
|
+ <span data-slot="session-turn-diff-path">
|
|
|
+ <Show when={diff.file.includes("/")}>
|
|
|
+ <span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
|
|
+ </Show>
|
|
|
+ <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
|
|
+ </span>
|
|
|
+ <div data-slot="session-turn-diff-meta">
|
|
|
+ <span data-slot="session-turn-diff-changes">
|
|
|
+ <DiffChanges changes={diff} />
|
|
|
+ </span>
|
|
|
+ <span data-slot="session-turn-diff-chevron">
|
|
|
+ <Icon name="chevron-down" size="small" />
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Accordion.Trigger>
|
|
|
+ </StickyAccordionHeader>
|
|
|
+ <Accordion.Content>
|
|
|
+ <Show when={opened()}>
|
|
|
+ <TimelineDiffView diff={diff} />
|
|
|
+ </Show>
|
|
|
+ </Accordion.Content>
|
|
|
+ </Accordion.Item>
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ </For>
|
|
|
+ </Accordion>
|
|
|
+ <Show when={!showAll() && overflow() > 0}>
|
|
|
+ <div data-slot="session-turn-diffs-more" onClick={() => setState("showAll", true)}>
|
|
|
+ {language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
)
|
|
|
+}
|
|
|
|
|
|
- const isStaging = createMemo(() => {
|
|
|
- const key = input.sessionKey()
|
|
|
- return state.activeSession === key && state.completedSession !== key
|
|
|
- })
|
|
|
+function TimelineDiffView(props: { diff: SummaryDiff }) {
|
|
|
+ const fileComponent = useFileComponent()
|
|
|
+ const view = normalize(props.diff)
|
|
|
|
|
|
- onCleanup(cancel)
|
|
|
- return { messages: stagedUserMessages, isStaging }
|
|
|
+ return (
|
|
|
+ <div data-slot="session-turn-diff-view" data-scrollable>
|
|
|
+ <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
export function MessageTimeline(props: {
|
|
|
- mobileChanges: boolean
|
|
|
- mobileFallback: JSX.Element
|
|
|
actions?: UserActions
|
|
|
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
|
|
onResumeScroll: () => void
|
|
|
@@ -219,16 +275,15 @@ export function MessageTimeline(props: {
|
|
|
onMarkScrollGesture: (target?: EventTarget | null) => void
|
|
|
hasScrollGesture: () => boolean
|
|
|
onUserScroll: () => void
|
|
|
- onTurnBackfillScroll: () => void
|
|
|
+ onHistoryScroll: () => void
|
|
|
onAutoScrollInteraction: (event: MouseEvent) => void
|
|
|
+ shouldAnchorBottom: () => boolean
|
|
|
centered: boolean
|
|
|
setContentRef: (el: HTMLDivElement) => void
|
|
|
- turnStart: number
|
|
|
- historyMore: boolean
|
|
|
- historyLoading: boolean
|
|
|
- onLoadEarlier: () => void
|
|
|
- renderedUserMessages: UserMessage[]
|
|
|
+ historyShift: boolean
|
|
|
+ userMessages: UserMessage[]
|
|
|
anchor: (id: string) => string
|
|
|
+ setRevealMessage?: (fn: (id: string) => void) => void
|
|
|
}) {
|
|
|
let touchGesture: number | undefined
|
|
|
|
|
|
@@ -242,13 +297,27 @@ export function MessageTimeline(props: {
|
|
|
const { params, sessionKey } = useSessionKey()
|
|
|
const platform = usePlatform()
|
|
|
|
|
|
- const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
|
|
+ let virtualizer: VirtualizerHandle | undefined
|
|
|
const sessionID = createMemo(() => params.id)
|
|
|
const sessionMessages = createMemo(() => {
|
|
|
const id = sessionID()
|
|
|
if (!id) return emptyMessages
|
|
|
return sync.data.message[id] ?? emptyMessages
|
|
|
})
|
|
|
+ const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
|
|
|
+ const assistantMessagesByParent = createMemo(() => {
|
|
|
+ const result = new Map<string, AssistantMessage[]>()
|
|
|
+ for (const message of sessionMessages()) {
|
|
|
+ if (message.role !== "assistant") continue
|
|
|
+ const messages = result.get(message.parentID)
|
|
|
+ if (messages) {
|
|
|
+ messages.push(message)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ result.set(message.parentID, [message])
|
|
|
+ }
|
|
|
+ return result
|
|
|
+ })
|
|
|
const pending = createMemo(() =>
|
|
|
sessionMessages().findLast(
|
|
|
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
|
|
@@ -317,11 +386,12 @@ export function MessageTimeline(props: {
|
|
|
return sync.data.message[id] ?? emptyMessages
|
|
|
})
|
|
|
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
|
|
+ const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
|
|
|
const childTaskDescription = createMemo(() => {
|
|
|
const id = sessionID()
|
|
|
if (!id) return
|
|
|
return parentMessages()
|
|
|
- .flatMap((message) => sync.data.part[message.id] ?? [])
|
|
|
+ .flatMap((message) => getMsgParts(message.id))
|
|
|
.map((part) => taskDescription(part, id))
|
|
|
.findLast((value): value is string => !!value)
|
|
|
})
|
|
|
@@ -333,12 +403,139 @@ export function MessageTimeline(props: {
|
|
|
return language.t("command.session.new")
|
|
|
})
|
|
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
|
|
- const stageCfg = { init: 1, batch: 3 }
|
|
|
- const staging = createTimelineStaging({
|
|
|
- sessionKey,
|
|
|
- turnStart: () => props.turnStart,
|
|
|
- messages: () => props.renderedUserMessages,
|
|
|
- config: stageCfg,
|
|
|
+
|
|
|
+ const messageRowMemos = createMemo(
|
|
|
+ mapArray(
|
|
|
+ () => props.userMessages,
|
|
|
+ (userMessage, indexAccessor) => {
|
|
|
+ return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
|
|
|
+ const rows = Timeline.constructMessageRows(
|
|
|
+ userMessage,
|
|
|
+ getMsgParts,
|
|
|
+ assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
|
|
|
+ indexAccessor(),
|
|
|
+ settings.general.showReasoningSummaries(),
|
|
|
+ sessionStatus().type,
|
|
|
+ activeMessageID() === userMessage.id,
|
|
|
+ )
|
|
|
+
|
|
|
+ return reuseTimelineRows(previous, rows)
|
|
|
+ })
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
|
|
|
+ const rows = messageRowMemos().flatMap((memo) => memo())
|
|
|
+ if (rows.length === 0) return rows
|
|
|
+ return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
|
|
|
+ })
|
|
|
+ const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
|
|
|
+ const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
|
|
|
+ const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
|
|
|
+ const messageRowIndex = createMemo(() => {
|
|
|
+ const result = new Map<string, number>()
|
|
|
+ timelineRows().forEach((row, index) => {
|
|
|
+ if (!("userMessageID" in row)) return
|
|
|
+ if (result.has(row.userMessageID)) return
|
|
|
+ result.set(row.userMessageID, index)
|
|
|
+ })
|
|
|
+ return result
|
|
|
+ })
|
|
|
+ const keepMounted = createMemo(() => {
|
|
|
+ const id = activeMessageID()
|
|
|
+ if (!id) return
|
|
|
+ const rows = timelineRows()
|
|
|
+ const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id)
|
|
|
+ if (index < 0) return
|
|
|
+ return [index]
|
|
|
+ })
|
|
|
+ const activeAssistantMessages = createMemo(() => {
|
|
|
+ const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id
|
|
|
+ if (!id) return emptyAssistantMessages
|
|
|
+ return assistantMessagesByParent().get(id) ?? emptyAssistantMessages
|
|
|
+ })
|
|
|
+ const activeAssistantContentVersion = createMemo(() =>
|
|
|
+ activeAssistantMessages()
|
|
|
+ .flatMap((message) => [
|
|
|
+ `${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`,
|
|
|
+ ...getMsgParts(message.id).map((part) => {
|
|
|
+ if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}`
|
|
|
+ if (part.type === "tool") {
|
|
|
+ const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
|
|
+ const output = "output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0
|
|
|
+ const metadataOutput =
|
|
|
+ metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string"
|
|
|
+ ? metadata.output.length
|
|
|
+ : 0
|
|
|
+ return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}`
|
|
|
+ }
|
|
|
+ return `${part.id}:${part.type}`
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ .join("|"),
|
|
|
+ )
|
|
|
+
|
|
|
+ createEffect(
|
|
|
+ on(
|
|
|
+ () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const,
|
|
|
+ () => {
|
|
|
+ if (!virtualizer) return
|
|
|
+ if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return
|
|
|
+ const keys = timelineRowKeys()
|
|
|
+ if (keys.length === 0) return
|
|
|
+ virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
|
|
|
+ scheduleMeasuredBottomAnchor()
|
|
|
+ },
|
|
|
+ { defer: true },
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ props.setRevealMessage?.((id) => {
|
|
|
+ const index = messageRowIndex().get(id)
|
|
|
+ if (index === undefined) return
|
|
|
+ virtualizer?.scrollToIndex(index, { align: "center" })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ let cacheSessionKey = sessionKey()
|
|
|
+ let cacheRowKeys = timelineRowKeys()
|
|
|
+ let virtualizerSessionKey = cacheSessionKey
|
|
|
+ let virtualizerRowKeys = cacheRowKeys
|
|
|
+ let bottomAnchorSessionKey = ""
|
|
|
+
|
|
|
+ const maybeAnchorBottom = () => {
|
|
|
+ const key = sessionKey()
|
|
|
+ if (bottomAnchorSessionKey === key) return
|
|
|
+ if (!virtualizer) return
|
|
|
+ const keys = timelineRowKeys()
|
|
|
+ if (keys.length === 0) return
|
|
|
+ bottomAnchorSessionKey = key
|
|
|
+ if (!props.shouldAnchorBottom()) return
|
|
|
+ virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
|
|
|
+ }
|
|
|
+
|
|
|
+ createEffect(
|
|
|
+ on(
|
|
|
+ () => [sessionKey(), timelineRowKeys()] as const,
|
|
|
+ (next, prev) => {
|
|
|
+ if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer)
|
|
|
+ cacheSessionKey = next[0]
|
|
|
+ cacheRowKeys = next[1]
|
|
|
+ if (virtualizer) {
|
|
|
+ virtualizerSessionKey = cacheSessionKey
|
|
|
+ virtualizerRowKeys = cacheRowKeys
|
|
|
+ maybeAnchorBottom()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { defer: true },
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ onCleanup(() => {
|
|
|
+ writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
|
|
|
+ props.setRevealMessage?.(() => {})
|
|
|
})
|
|
|
|
|
|
const [title, setTitle] = createStore({
|
|
|
@@ -360,14 +557,150 @@ export function MessageTimeline(props: {
|
|
|
|
|
|
let more: HTMLButtonElement | undefined
|
|
|
let head: HTMLDivElement | undefined
|
|
|
+ let listRoot: HTMLDivElement | undefined
|
|
|
+ let listFrame: number | undefined
|
|
|
+ let contentFrame: number | undefined
|
|
|
+ let bottomAnchorFrame: number | undefined
|
|
|
+ let bottomAnchorFrames = 0
|
|
|
+ let measuredBottomAnchored = true
|
|
|
+ const [scrollRoot, setScrollRoot] = createSignal<HTMLDivElement>()
|
|
|
+
|
|
|
+ const updateTitleMetrics = () => {
|
|
|
+ if (!head || head.clientWidth <= 0) return
|
|
|
+ setBar("ms", pace(head.clientWidth))
|
|
|
+ }
|
|
|
|
|
|
- createResizeObserver(
|
|
|
- () => head,
|
|
|
- () => {
|
|
|
- if (!head || head.clientWidth <= 0) return
|
|
|
- setBar("ms", pace(head.clientWidth))
|
|
|
- },
|
|
|
- )
|
|
|
+ createResizeObserver(() => head, updateTitleMetrics)
|
|
|
+
|
|
|
+ const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
|
|
|
+
|
|
|
+ function anchorMeasuredBottom() {
|
|
|
+ if (!listRoot) return false
|
|
|
+ if (!measuredBottomAnchored) return false
|
|
|
+ listRoot.scrollTop = listRoot.scrollHeight
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ function scheduleMeasuredBottomAnchor() {
|
|
|
+ // Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for
|
|
|
+ // "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual
|
|
|
+ // height, so keep the scroll container bottom-locked for a few frames while measurement settles.
|
|
|
+ bottomAnchorFrames = 90
|
|
|
+ if (bottomAnchorFrame !== undefined) return
|
|
|
+
|
|
|
+ const tick = () => {
|
|
|
+ bottomAnchorFrame = undefined
|
|
|
+ if (!anchorMeasuredBottom()) {
|
|
|
+ bottomAnchorFrames = 0
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1
|
|
|
+ if (bottomAnchorFrames <= 0) return
|
|
|
+ bottomAnchorFrame = requestAnimationFrame(tick)
|
|
|
+ }
|
|
|
+
|
|
|
+ bottomAnchorFrame = requestAnimationFrame(tick)
|
|
|
+ }
|
|
|
+
|
|
|
+ const bindContentRoot = (root: HTMLDivElement) => {
|
|
|
+ const child = root.firstElementChild
|
|
|
+ props.setContentRef(child instanceof HTMLDivElement ? child : root)
|
|
|
+ }
|
|
|
+
|
|
|
+ const scheduleContentRoot = (root: HTMLDivElement) => {
|
|
|
+ if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
|
|
+ contentFrame = requestAnimationFrame(() => {
|
|
|
+ contentFrame = undefined
|
|
|
+ if (listRoot !== root) return
|
|
|
+ bindContentRoot(root)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const connectListRoot = (root: HTMLDivElement) => {
|
|
|
+ if (listRoot !== root) return
|
|
|
+ if (!root.isConnected || !root.ownerDocument.defaultView) {
|
|
|
+ listFrame = requestAnimationFrame(() => {
|
|
|
+ listFrame = undefined
|
|
|
+ connectListRoot(root)
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ props.setScrollRef(root)
|
|
|
+ measuredBottomAnchored = isMeasuredBottom(root)
|
|
|
+ setScrollRoot(root)
|
|
|
+ scheduleContentRoot(root)
|
|
|
+ }
|
|
|
+
|
|
|
+ const bindListRoot = (root: HTMLDivElement) => {
|
|
|
+ if (root === listRoot) return
|
|
|
+
|
|
|
+ if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
|
|
+ if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
|
|
+ listRoot = root
|
|
|
+ setScrollRoot(undefined)
|
|
|
+ connectListRoot(root)
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
|
|
+ const root = event.currentTarget
|
|
|
+ const delta = normalizeWheelDelta({
|
|
|
+ deltaY: event.deltaY,
|
|
|
+ deltaMode: event.deltaMode,
|
|
|
+ rootHeight: root.clientHeight,
|
|
|
+ })
|
|
|
+ if (!delta) return
|
|
|
+ markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleListTouchStart = (event: TouchEvent) => {
|
|
|
+ touchGesture = event.touches[0]?.clientY
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
|
|
|
+ const next = event.touches[0]?.clientY
|
|
|
+ const prev = touchGesture
|
|
|
+ touchGesture = next
|
|
|
+ if (next === undefined || prev === undefined) return
|
|
|
+
|
|
|
+ const delta = prev - next
|
|
|
+ if (!delta) return
|
|
|
+
|
|
|
+ markBoundaryGesture({
|
|
|
+ root: event.currentTarget,
|
|
|
+ target: event.target,
|
|
|
+ delta,
|
|
|
+ onMarkScrollGesture: props.onMarkScrollGesture,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleListTouchEnd = () => {
|
|
|
+ touchGesture = undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
|
|
+ if (event.target !== event.currentTarget) return
|
|
|
+ props.onMarkScrollGesture(event.currentTarget)
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
|
+ measuredBottomAnchored = isMeasuredBottom(event.currentTarget)
|
|
|
+ props.onScheduleScrollState(event.currentTarget)
|
|
|
+ props.onHistoryScroll()
|
|
|
+ if (!props.hasScrollGesture()) return
|
|
|
+ props.onUserScroll()
|
|
|
+ props.onAutoScrollHandleScroll()
|
|
|
+ props.onMarkScrollGesture(event.currentTarget)
|
|
|
+ }
|
|
|
+
|
|
|
+ onCleanup(() => {
|
|
|
+ if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
|
|
+ if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
|
|
+ if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
|
|
+ setScrollRoot(undefined)
|
|
|
+ props.setScrollRef(undefined)
|
|
|
+ })
|
|
|
|
|
|
const viewShare = () => {
|
|
|
const url = shareUrl()
|
|
|
@@ -623,496 +956,629 @@ export function MessageTimeline(props: {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
|
|
|
+
|
|
|
+ const turnDurationMs = (userMessageID: string) => {
|
|
|
+ const message = messageByID().get(userMessageID)
|
|
|
+ if (!message || message.role !== "user") return
|
|
|
+ const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce<number | undefined>(
|
|
|
+ (max, item) => {
|
|
|
+ const completed = item.time.completed
|
|
|
+ if (typeof completed !== "number") return max
|
|
|
+ if (max === undefined) return completed
|
|
|
+ return Math.max(max, completed)
|
|
|
+ },
|
|
|
+ undefined,
|
|
|
+ )
|
|
|
+ if (typeof end !== "number") return
|
|
|
+ if (end < message.time.created) return
|
|
|
+ return end - message.time.created
|
|
|
+ }
|
|
|
+
|
|
|
+ const assistantCopyPartID = (userMessageID: string) => {
|
|
|
+ if (workingTurn(userMessageID)) return null
|
|
|
+ const messages = assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages
|
|
|
+
|
|
|
+ for (let i = messages.length - 1; i >= 0; i--) {
|
|
|
+ const message = messages[i]
|
|
|
+ if (!message) continue
|
|
|
+
|
|
|
+ const parts = getMsgParts(message.id)
|
|
|
+ for (let j = parts.length - 1; j >= 0; j--) {
|
|
|
+ const part = parts[j]
|
|
|
+ if (!part || part.type !== "text" || !part.text?.trim()) continue
|
|
|
+ return part.id
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
|
|
+
|
|
|
+ const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
|
|
|
+ if (untrack(row).group.type === "context") {
|
|
|
+ const parts = createMemo(() => {
|
|
|
+ const group = row().group
|
|
|
+ if (group.type !== "context") return emptyTools
|
|
|
+ return group.refs
|
|
|
+ .map((ref) => getMsgPart(ref.messageID, ref.partID))
|
|
|
+ .filter((part): part is ToolPart => part?.type === "tool")
|
|
|
+ })
|
|
|
+
|
|
|
+ return <ContextToolGroup parts={parts()} busy={workingTurn(row().userMessageID) && row().lastAssistantPart} />
|
|
|
+ }
|
|
|
+
|
|
|
+ const message = createMemo(() => {
|
|
|
+ const group = row().group
|
|
|
+ if (group.type !== "part") return
|
|
|
+ return messageByID().get(group.ref.messageID)
|
|
|
+ })
|
|
|
+ const part = createMemo(() => {
|
|
|
+ const group = row().group
|
|
|
+ if (group.type !== "part") return
|
|
|
+ return getMsgPart(group.ref.messageID, group.ref.partID)
|
|
|
+ })
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Show when={message()}>
|
|
|
+ {(message) => (
|
|
|
+ <Show when={part()}>
|
|
|
+ {(part) => (
|
|
|
+ <MessagePart
|
|
|
+ part={part()}
|
|
|
+ message={message()}
|
|
|
+ showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
|
|
+ turnDurationMs={turnDurationMs(row().userMessageID)}
|
|
|
+ defaultOpen={partDefaultOpen(
|
|
|
+ part(),
|
|
|
+ settings.general.shellToolPartsExpanded(),
|
|
|
+ settings.general.editToolPartsExpanded(),
|
|
|
+ )}
|
|
|
+ deferToolContent={false}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
|
|
|
+ const anchor = () => {
|
|
|
+ const row = input.row()
|
|
|
+ return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
|
|
+ }
|
|
|
+ const previousUserMessage = () => {
|
|
|
+ const row = input.row()
|
|
|
+ return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
|
|
|
+ }
|
|
|
+ const previousAssistantPart = () => {
|
|
|
+ const row = input.row()
|
|
|
+ return row._tag === "AssistantPart" && row.previousAssistantPart
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
|
|
|
+ data-message-id={input.row().userMessageID}
|
|
|
+ data-timeline-row={input.row()._tag}
|
|
|
+ classList={{
|
|
|
+ "min-w-0 w-full max-w-full": true,
|
|
|
+ "md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
|
|
+ "md:mx-auto": props.centered,
|
|
|
+ "pt-6": previousUserMessage(),
|
|
|
+ "pt-3": previousAssistantPart(),
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div data-component="session-turn" class="min-w-0 w-full relative" style={{ height: "auto" }}>
|
|
|
+ {input.children}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
|
|
|
+ switch (row()._tag) {
|
|
|
+ case "CommentStrip": {
|
|
|
+ const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
|
|
|
+ const comments = createMemo(() =>
|
|
|
+ getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
|
|
|
+ )
|
|
|
+ return (
|
|
|
+ <TimelineRowFrame row={commentStripRow}>
|
|
|
+ <div class="w-full px-4 md:px-5 pb-2">
|
|
|
+ <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
|
|
+ <div class="flex w-max min-w-full justify-end gap-2">
|
|
|
+ <Index each={comments()}>
|
|
|
+ {(comment) => (
|
|
|
+ <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
|
|
+ <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
|
|
+ <FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
|
|
|
+ <span class="truncate">{getFilename(comment().path)}</span>
|
|
|
+ <Show when={comment().selection}>
|
|
|
+ {(selection) => (
|
|
|
+ <span class="shrink-0 text-text-weak">
|
|
|
+ {selection().startLine === selection().endLine
|
|
|
+ ? `:${selection().startLine}`
|
|
|
+ : `:${selection().startLine}-${selection().endLine}`}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
|
|
+ {comment().comment}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Index>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </TimelineRowFrame>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "UserMessage": {
|
|
|
+ const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
|
|
|
+ const message = createMemo(() => {
|
|
|
+ const m = messageByID().get(userMessageRow().userMessageID)
|
|
|
+ if (m?.role === "user") return m
|
|
|
+ })
|
|
|
+ return (
|
|
|
+ <TimelineRowFrame row={userMessageRow}>
|
|
|
+ <Show when={message()}>
|
|
|
+ {(message) => (
|
|
|
+ <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
|
|
+ <div data-slot="session-turn-message-content" aria-live="off">
|
|
|
+ <Message message={message()} parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ </TimelineRowFrame>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "TurnDivider": {
|
|
|
+ const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
|
|
+ return (
|
|
|
+ <TimelineRowFrame row={turnDividerRow}>
|
|
|
+ <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
|
|
+ <div data-slot="session-turn-compaction">
|
|
|
+ <MessageDivider
|
|
|
+ label={language.t(
|
|
|
+ turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </TimelineRowFrame>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "AssistantPart": {
|
|
|
+ const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
|
|
+ return (
|
|
|
+ <TimelineRowFrame row={assistantPartRow}>
|
|
|
+ <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
|
|
+ <div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(assistantPartRow().userMessageID)}>
|
|
|
+ {renderAssistantPartGroup(assistantPartRow)}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </TimelineRowFrame>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "Thinking": {
|
|
|
+ const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
|
|
+ return (
|
|
|
+ <TimelineRowFrame row={thinkingRow}>
|
|
|
+ <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
|
|
+ <TimelineThinkingRow
|
|
|
+ reasoningHeading={thinkingRow().reasoningHeading}
|
|
|
+ showReasoningSummaries={settings.general.showReasoningSummaries()}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </TimelineRowFrame>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "Retry": {
|
|
|
+ const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
|
|
+ return (
|
|
|
+ <TimelineRowFrame row={retryRow}>
|
|
|
+ <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
|
|
+ <SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
|
|
+ </div>
|
|
|
+ </TimelineRowFrame>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "DiffSummary": {
|
|
|
+ const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
|
|
+ return (
|
|
|
+ <TimelineRowFrame row={diffSummaryRow}>
|
|
|
+ <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
|
|
+ <TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
|
|
+ </div>
|
|
|
+ </TimelineRowFrame>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "Error": {
|
|
|
+ const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
|
|
+ return (
|
|
|
+ <TimelineRowFrame row={errorRow}>
|
|
|
+ <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
|
|
+ <Card variant="error" class="error-card">
|
|
|
+ {errorRow().text}
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ </TimelineRowFrame>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "BottomSpacer":
|
|
|
+ return <div data-timeline-row="bottom-spacer" aria-hidden="true" class="h-16" />
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function TimelineRowView(props: { rowKey: string }) {
|
|
|
+ return (
|
|
|
+ <Show when={timelineRowByKey().get(props.rowKey)}>
|
|
|
+ {(item) => renderTimelineRow(item)}
|
|
|
+ </Show>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
return (
|
|
|
- <Show
|
|
|
- when={!props.mobileChanges}
|
|
|
- fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>}
|
|
|
- >
|
|
|
- <div class="relative w-full h-full min-w-0">
|
|
|
- <div
|
|
|
- class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
|
|
- classList={{
|
|
|
- "opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump && !staging.isStaging(),
|
|
|
- "opacity-0 translate-y-2 scale-95 pointer-events-none":
|
|
|
- !props.scroll.overflow || !props.scroll.jump || staging.isStaging(),
|
|
|
- }}
|
|
|
+ <div class="relative w-full h-full min-w-0">
|
|
|
+ <div
|
|
|
+ class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
|
|
+ classList={{
|
|
|
+ "opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
|
|
|
+ "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
|
|
+ onClick={props.onResumeScroll}
|
|
|
>
|
|
|
- <button
|
|
|
- class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
|
|
- onClick={props.onResumeScroll}
|
|
|
+ <div
|
|
|
+ class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
|
|
+ style={{
|
|
|
+ "box-shadow":
|
|
|
+ "0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
|
|
+ }}
|
|
|
>
|
|
|
- <div
|
|
|
- class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
|
|
- style={{
|
|
|
- "box-shadow":
|
|
|
- "0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
|
|
- }}
|
|
|
- >
|
|
|
- <Icon name="arrow-down-to-line" size="small" />
|
|
|
- </div>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- <ScrollView
|
|
|
- viewportRef={props.setScrollRef}
|
|
|
- onWheel={(e) => {
|
|
|
- const root = e.currentTarget
|
|
|
- const delta = normalizeWheelDelta({
|
|
|
- deltaY: e.deltaY,
|
|
|
- deltaMode: e.deltaMode,
|
|
|
- rootHeight: root.clientHeight,
|
|
|
- })
|
|
|
- if (!delta) return
|
|
|
- markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
|
|
- }}
|
|
|
- onTouchStart={(e) => {
|
|
|
- touchGesture = e.touches[0]?.clientY
|
|
|
- }}
|
|
|
- onTouchMove={(e) => {
|
|
|
- const next = e.touches[0]?.clientY
|
|
|
- const prev = touchGesture
|
|
|
- touchGesture = next
|
|
|
- if (next === undefined || prev === undefined) return
|
|
|
-
|
|
|
- const delta = prev - next
|
|
|
- if (!delta) return
|
|
|
-
|
|
|
- const root = e.currentTarget
|
|
|
- markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
|
|
- }}
|
|
|
- onTouchEnd={() => {
|
|
|
- touchGesture = undefined
|
|
|
- }}
|
|
|
- onTouchCancel={() => {
|
|
|
- touchGesture = undefined
|
|
|
- }}
|
|
|
- onPointerDown={(e) => {
|
|
|
- if (e.target !== e.currentTarget) return
|
|
|
- props.onMarkScrollGesture(e.currentTarget)
|
|
|
- }}
|
|
|
- onScroll={(e) => {
|
|
|
- props.onScheduleScrollState(e.currentTarget)
|
|
|
- props.onTurnBackfillScroll()
|
|
|
- if (!props.hasScrollGesture()) return
|
|
|
- props.onUserScroll()
|
|
|
- props.onAutoScrollHandleScroll()
|
|
|
- props.onMarkScrollGesture(e.currentTarget)
|
|
|
- }}
|
|
|
- onClick={props.onAutoScrollInteraction}
|
|
|
- class="relative min-w-0 w-full h-full"
|
|
|
- style={{
|
|
|
- "--session-title-height": showHeader() ? "40px" : "0px",
|
|
|
- "--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
|
|
- }}
|
|
|
- >
|
|
|
- <div ref={props.setContentRef} class="min-w-0 w-full">
|
|
|
- <Show when={showHeader()}>
|
|
|
+ <Icon name="arrow-down-to-line" size="small" />
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <ScrollView
|
|
|
+ viewportRef={bindListRoot}
|
|
|
+ onWheel={handleListWheel}
|
|
|
+ onTouchStart={handleListTouchStart}
|
|
|
+ onTouchMove={handleListTouchMove}
|
|
|
+ onTouchEnd={handleListTouchEnd}
|
|
|
+ onTouchCancel={handleListTouchEnd}
|
|
|
+ onPointerDown={handleListPointerDown}
|
|
|
+ onScroll={handleListScroll}
|
|
|
+ onClick={props.onAutoScrollInteraction}
|
|
|
+ class="relative min-w-0 w-full h-full"
|
|
|
+ style={{
|
|
|
+ "--session-title-height": showHeader() ? "40px" : "0px",
|
|
|
+ "--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Show when={showHeader()}>
|
|
|
+ <div
|
|
|
+ ref={(el) => {
|
|
|
+ head = el
|
|
|
+ updateTitleMetrics()
|
|
|
+ }}
|
|
|
+ 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,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
|
|
|
<div
|
|
|
- ref={(el) => {
|
|
|
- head = el
|
|
|
- setBar("ms", pace(el.clientWidth))
|
|
|
- }}
|
|
|
- data-session-title
|
|
|
- classList={{
|
|
|
- "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
|
|
- relative: 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,
|
|
|
+ data-component="session-progress"
|
|
|
+ data-state={workingStatus()}
|
|
|
+ aria-hidden="true"
|
|
|
+ style={{
|
|
|
+ "--session-progress-color": tint() ?? "var(--icon-interactive-base)",
|
|
|
+ "--session-progress-ms": `${bar.ms}ms`,
|
|
|
}}
|
|
|
>
|
|
|
- <Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
|
|
|
+ <div data-component="session-progress-bar" />
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ <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">
|
|
|
+ <div class="flex items-center min-w-0 grow-1">
|
|
|
+ <Show when={parentID()}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ data-slot="session-title-parent"
|
|
|
+ class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
|
|
+ onClick={navigateParent}
|
|
|
+ >
|
|
|
+ {parentTitle()}
|
|
|
+ </button>
|
|
|
+ <span
|
|
|
+ data-slot="session-title-separator"
|
|
|
+ class="px-2 text-14-medium text-text-weak"
|
|
|
+ aria-hidden="true"
|
|
|
+ >
|
|
|
+ /
|
|
|
+ </span>
|
|
|
+ </Show>
|
|
|
<div
|
|
|
- data-component="session-progress"
|
|
|
- data-state={workingStatus()}
|
|
|
- aria-hidden="true"
|
|
|
+ class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
|
|
style={{
|
|
|
- "--session-progress-color": tint() ?? "var(--icon-interactive-base)",
|
|
|
- "--session-progress-ms": `${bar.ms}ms`,
|
|
|
+ width: working() ? "16px" : "0px",
|
|
|
+ "margin-right": working() ? "8px" : "0px",
|
|
|
}}
|
|
|
+ aria-hidden="true"
|
|
|
>
|
|
|
- <div data-component="session-progress-bar" />
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- <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">
|
|
|
- <div class="flex items-center min-w-0 grow-1">
|
|
|
- <Show when={parentID()}>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- data-slot="session-title-parent"
|
|
|
- class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
|
|
- onClick={navigateParent}
|
|
|
- >
|
|
|
- {parentTitle()}
|
|
|
- </button>
|
|
|
- <span
|
|
|
- data-slot="session-title-separator"
|
|
|
- class="px-2 text-14-medium text-text-weak"
|
|
|
- aria-hidden="true"
|
|
|
- >
|
|
|
- /
|
|
|
- </span>
|
|
|
- </Show>
|
|
|
+ <Show when={workingStatus() !== "hidden"}>
|
|
|
<div
|
|
|
- class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
|
|
- style={{
|
|
|
- width: working() ? "16px" : "0px",
|
|
|
- "margin-right": working() ? "8px" : "0px",
|
|
|
- }}
|
|
|
- aria-hidden="true"
|
|
|
+ class="transition-opacity duration-200 ease-out"
|
|
|
+ classList={{ "opacity-0": workingStatus() === "hiding" }}
|
|
|
>
|
|
|
- <Show when={workingStatus() !== "hidden"}>
|
|
|
- <div
|
|
|
- class="transition-opacity duration-200 ease-out"
|
|
|
- classList={{ "opacity-0": workingStatus() === "hiding" }}
|
|
|
- >
|
|
|
- <Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
+ <Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
|
|
</div>
|
|
|
- <Show when={childTitle() || title.editing}>
|
|
|
- <Show
|
|
|
- when={title.editing}
|
|
|
- fallback={
|
|
|
- <h1
|
|
|
- data-slot="session-title-child"
|
|
|
- class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
|
|
- onDblClick={openTitleEditor}
|
|
|
- >
|
|
|
- {childTitle()}
|
|
|
- </h1>
|
|
|
- }
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ <Show when={childTitle() || title.editing}>
|
|
|
+ <Show
|
|
|
+ when={title.editing}
|
|
|
+ fallback={
|
|
|
+ <h1
|
|
|
+ data-slot="session-title-child"
|
|
|
+ class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
|
|
+ onDblClick={openTitleEditor}
|
|
|
>
|
|
|
- <InlineInput
|
|
|
- ref={(el) => {
|
|
|
- titleRef = el
|
|
|
- }}
|
|
|
- data-slot="session-title-child"
|
|
|
- value={title.draft}
|
|
|
- disabled={titleMutation.isPending}
|
|
|
- class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
|
|
|
- style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
|
|
- onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
|
|
- onKeyDown={(event) => {
|
|
|
- event.stopPropagation()
|
|
|
- if (event.key === "Enter") {
|
|
|
+ {childTitle()}
|
|
|
+ </h1>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <InlineInput
|
|
|
+ ref={(el) => {
|
|
|
+ titleRef = el
|
|
|
+ }}
|
|
|
+ data-slot="session-title-child"
|
|
|
+ value={title.draft}
|
|
|
+ disabled={titleMutation.isPending}
|
|
|
+ class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
|
|
|
+ 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>
|
|
|
+ </div>
|
|
|
+ <Show when={sessionID()} keyed>
|
|
|
+ {(id) => (
|
|
|
+ <div class="shrink-0 flex items-center gap-3">
|
|
|
+ <SessionContextUsage placement="bottom" />
|
|
|
+ <Show when={!parentID()}>
|
|
|
+ <DropdownMenu
|
|
|
+ gutter={4}
|
|
|
+ placement="bottom-end"
|
|
|
+ open={title.menuOpen}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ setTitle("menuOpen", open)
|
|
|
+ if (open) return
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <DropdownMenu.Trigger
|
|
|
+ as={IconButton}
|
|
|
+ icon="dot-grid"
|
|
|
+ variant="ghost"
|
|
|
+ class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
|
+ classList={{
|
|
|
+ "bg-surface-base-active": share.open || title.pendingShare,
|
|
|
+ }}
|
|
|
+ aria-label={language.t("common.moreOptions")}
|
|
|
+ aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
|
|
+ ref={(el: HTMLButtonElement) => {
|
|
|
+ more = el
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <DropdownMenu.Portal>
|
|
|
+ <DropdownMenu.Content
|
|
|
+ style={{ "min-width": "104px" }}
|
|
|
+ onCloseAutoFocus={(event) => {
|
|
|
+ if (title.pendingRename) {
|
|
|
event.preventDefault()
|
|
|
- void saveTitleEditor()
|
|
|
+ setTitle("pendingRename", false)
|
|
|
+ openTitleEditor()
|
|
|
return
|
|
|
}
|
|
|
- if (event.key === "Escape") {
|
|
|
+ if (title.pendingShare) {
|
|
|
event.preventDefault()
|
|
|
- closeTitleEditor()
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ setShare({ open: true, dismiss: null })
|
|
|
+ setTitle("pendingShare", false)
|
|
|
+ })
|
|
|
}
|
|
|
}}
|
|
|
- onBlur={closeTitleEditor}
|
|
|
- />
|
|
|
- </Show>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <Show when={sessionID()} keyed>
|
|
|
- {(id) => (
|
|
|
- <div class="shrink-0 flex items-center gap-3">
|
|
|
- <SessionContextUsage placement="bottom" />
|
|
|
- <Show when={!parentID()}>
|
|
|
- <DropdownMenu
|
|
|
- gutter={4}
|
|
|
- placement="bottom-end"
|
|
|
- open={title.menuOpen}
|
|
|
- onOpenChange={(open) => {
|
|
|
- setTitle("menuOpen", open)
|
|
|
- if (open) return
|
|
|
- }}
|
|
|
>
|
|
|
- <DropdownMenu.Trigger
|
|
|
- as={IconButton}
|
|
|
- icon="dot-grid"
|
|
|
- variant="ghost"
|
|
|
- class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
|
- classList={{
|
|
|
- "bg-surface-base-active": share.open || title.pendingShare,
|
|
|
- }}
|
|
|
- aria-label={language.t("common.moreOptions")}
|
|
|
- aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
|
|
- ref={(el: HTMLButtonElement) => {
|
|
|
- more = el
|
|
|
+ <DropdownMenu.Item
|
|
|
+ onSelect={() => {
|
|
|
+ setTitle("pendingRename", true)
|
|
|
+ setTitle("menuOpen", false)
|
|
|
}}
|
|
|
- />
|
|
|
- <DropdownMenu.Portal>
|
|
|
- <DropdownMenu.Content
|
|
|
- style={{ "min-width": "104px" }}
|
|
|
- onCloseAutoFocus={(event) => {
|
|
|
- if (title.pendingRename) {
|
|
|
- event.preventDefault()
|
|
|
- setTitle("pendingRename", false)
|
|
|
- openTitleEditor()
|
|
|
- return
|
|
|
- }
|
|
|
- if (title.pendingShare) {
|
|
|
- event.preventDefault()
|
|
|
- requestAnimationFrame(() => {
|
|
|
- setShare({ open: true, dismiss: null })
|
|
|
- setTitle("pendingShare", false)
|
|
|
- })
|
|
|
- }
|
|
|
+ >
|
|
|
+ <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
|
+ </DropdownMenu.Item>
|
|
|
+ <Show when={shareEnabled()}>
|
|
|
+ <DropdownMenu.Item
|
|
|
+ onSelect={() => {
|
|
|
+ setTitle({ pendingShare: true, menuOpen: false })
|
|
|
}}
|
|
|
>
|
|
|
- <DropdownMenu.Item
|
|
|
- onSelect={() => {
|
|
|
- setTitle("pendingRename", true)
|
|
|
- setTitle("menuOpen", false)
|
|
|
- }}
|
|
|
- >
|
|
|
- <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
|
- </DropdownMenu.Item>
|
|
|
- <Show when={shareEnabled()}>
|
|
|
- <DropdownMenu.Item
|
|
|
- onSelect={() => {
|
|
|
- setTitle({ pendingShare: true, menuOpen: false })
|
|
|
- }}
|
|
|
- >
|
|
|
- <DropdownMenu.ItemLabel>
|
|
|
- {language.t("session.share.action.share")}
|
|
|
- </DropdownMenu.ItemLabel>
|
|
|
- </DropdownMenu.Item>
|
|
|
- </Show>
|
|
|
- <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>
|
|
|
-
|
|
|
- <KobaltePopover
|
|
|
- open={share.open}
|
|
|
- anchorRef={() => more}
|
|
|
- placement="bottom-end"
|
|
|
- gutter={4}
|
|
|
- modal={false}
|
|
|
- onOpenChange={(open) => {
|
|
|
- if (open) setShare("dismiss", null)
|
|
|
- setShare("open", open)
|
|
|
+ <DropdownMenu.ItemLabel>
|
|
|
+ {language.t("session.share.action.share")}
|
|
|
+ </DropdownMenu.ItemLabel>
|
|
|
+ </DropdownMenu.Item>
|
|
|
+ </Show>
|
|
|
+ <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>
|
|
|
+
|
|
|
+ <KobaltePopover
|
|
|
+ open={share.open}
|
|
|
+ anchorRef={() => more}
|
|
|
+ placement="bottom-end"
|
|
|
+ gutter={4}
|
|
|
+ modal={false}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ if (open) setShare("dismiss", null)
|
|
|
+ setShare("open", open)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <KobaltePopover.Portal>
|
|
|
+ <KobaltePopover.Content
|
|
|
+ data-component="popover-content"
|
|
|
+ style={{ "min-width": "320px" }}
|
|
|
+ onEscapeKeyDown={(event) => {
|
|
|
+ setShare({ dismiss: "escape", open: false })
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ }}
|
|
|
+ onPointerDownOutside={() => {
|
|
|
+ setShare({ dismiss: "outside", open: false })
|
|
|
+ }}
|
|
|
+ onFocusOutside={() => {
|
|
|
+ setShare({ dismiss: "outside", open: false })
|
|
|
+ }}
|
|
|
+ onCloseAutoFocus={(event) => {
|
|
|
+ if (share.dismiss === "outside") event.preventDefault()
|
|
|
+ setShare("dismiss", null)
|
|
|
}}
|
|
|
>
|
|
|
- <KobaltePopover.Portal>
|
|
|
- <KobaltePopover.Content
|
|
|
- data-component="popover-content"
|
|
|
- style={{ "min-width": "320px" }}
|
|
|
- onEscapeKeyDown={(event) => {
|
|
|
- setShare({ dismiss: "escape", open: false })
|
|
|
- event.preventDefault()
|
|
|
- event.stopPropagation()
|
|
|
- }}
|
|
|
- onPointerDownOutside={() => {
|
|
|
- setShare({ dismiss: "outside", open: false })
|
|
|
- }}
|
|
|
- onFocusOutside={() => {
|
|
|
- setShare({ dismiss: "outside", open: false })
|
|
|
- }}
|
|
|
- onCloseAutoFocus={(event) => {
|
|
|
- if (share.dismiss === "outside") event.preventDefault()
|
|
|
- setShare("dismiss", null)
|
|
|
- }}
|
|
|
- >
|
|
|
- <div class="flex flex-col p-3">
|
|
|
- <div class="flex flex-col gap-1">
|
|
|
- <div class="text-13-medium text-text-strong">
|
|
|
- {language.t("session.share.popover.title")}
|
|
|
- </div>
|
|
|
- <div class="text-12-regular text-text-weak">
|
|
|
- {shareUrl()
|
|
|
- ? language.t("session.share.popover.description.shared")
|
|
|
- : language.t("session.share.popover.description.unshared")}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="mt-3 flex flex-col gap-2">
|
|
|
- <Show
|
|
|
- when={shareUrl()}
|
|
|
- fallback={
|
|
|
- <Button
|
|
|
- size="large"
|
|
|
- variant="primary"
|
|
|
- class="w-full"
|
|
|
- onClick={shareSession}
|
|
|
- disabled={shareMutation.isPending}
|
|
|
- >
|
|
|
- {shareMutation.isPending
|
|
|
- ? language.t("session.share.action.publishing")
|
|
|
- : language.t("session.share.action.publish")}
|
|
|
- </Button>
|
|
|
- }
|
|
|
+ <div class="flex flex-col p-3">
|
|
|
+ <div class="flex flex-col gap-1">
|
|
|
+ <div class="text-13-medium text-text-strong">
|
|
|
+ {language.t("session.share.popover.title")}
|
|
|
+ </div>
|
|
|
+ <div class="text-12-regular text-text-weak">
|
|
|
+ {shareUrl()
|
|
|
+ ? language.t("session.share.popover.description.shared")
|
|
|
+ : language.t("session.share.popover.description.unshared")}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-3 flex flex-col gap-2">
|
|
|
+ <Show
|
|
|
+ when={shareUrl()}
|
|
|
+ fallback={
|
|
|
+ <Button
|
|
|
+ size="large"
|
|
|
+ variant="primary"
|
|
|
+ class="w-full"
|
|
|
+ onClick={shareSession}
|
|
|
+ disabled={shareMutation.isPending}
|
|
|
>
|
|
|
- <div class="flex flex-col gap-2">
|
|
|
- <TextField
|
|
|
- value={shareUrl() ?? ""}
|
|
|
- readOnly
|
|
|
- copyable
|
|
|
- copyKind="link"
|
|
|
- tabIndex={-1}
|
|
|
- class="w-full"
|
|
|
- />
|
|
|
- <div class="grid grid-cols-2 gap-2">
|
|
|
- <Button
|
|
|
- size="large"
|
|
|
- variant="secondary"
|
|
|
- class="w-full shadow-none border border-border-weak-base"
|
|
|
- onClick={unshareSession}
|
|
|
- disabled={unshareMutation.isPending}
|
|
|
- >
|
|
|
- {unshareMutation.isPending
|
|
|
- ? language.t("session.share.action.unpublishing")
|
|
|
- : language.t("session.share.action.unpublish")}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- size="large"
|
|
|
- variant="primary"
|
|
|
- class="w-full"
|
|
|
- onClick={viewShare}
|
|
|
- disabled={unshareMutation.isPending}
|
|
|
- >
|
|
|
- {language.t("session.share.action.view")}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
+ {shareMutation.isPending
|
|
|
+ ? language.t("session.share.action.publishing")
|
|
|
+ : language.t("session.share.action.publish")}
|
|
|
+ </Button>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <div class="flex flex-col gap-2">
|
|
|
+ <TextField
|
|
|
+ value={shareUrl() ?? ""}
|
|
|
+ readOnly
|
|
|
+ copyable
|
|
|
+ copyKind="link"
|
|
|
+ tabIndex={-1}
|
|
|
+ class="w-full"
|
|
|
+ />
|
|
|
+ <div class="grid grid-cols-2 gap-2">
|
|
|
+ <Button
|
|
|
+ size="large"
|
|
|
+ variant="secondary"
|
|
|
+ class="w-full shadow-none border border-border-weak-base"
|
|
|
+ onClick={unshareSession}
|
|
|
+ disabled={unshareMutation.isPending}
|
|
|
+ >
|
|
|
+ {unshareMutation.isPending
|
|
|
+ ? language.t("session.share.action.unpublishing")
|
|
|
+ : language.t("session.share.action.unpublish")}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ size="large"
|
|
|
+ variant="primary"
|
|
|
+ class="w-full"
|
|
|
+ onClick={viewShare}
|
|
|
+ disabled={unshareMutation.isPending}
|
|
|
+ >
|
|
|
+ {language.t("session.share.action.view")}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- </KobaltePopover.Content>
|
|
|
- </KobaltePopover.Portal>
|
|
|
- </KobaltePopover>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- <div
|
|
|
- role="log"
|
|
|
- data-slot="session-turn-list"
|
|
|
- class="flex flex-col items-start justify-start pb-16 transition-[margin]"
|
|
|
- classList={{
|
|
|
- "w-full": true,
|
|
|
- "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
|
|
- "mt-0.5": props.centered,
|
|
|
- "mt-0": !props.centered,
|
|
|
- }}
|
|
|
- >
|
|
|
- <Show when={props.turnStart > 0 || props.historyMore}>
|
|
|
- <div class="w-full flex justify-center">
|
|
|
- <Button
|
|
|
- variant="ghost"
|
|
|
- size="large"
|
|
|
- class="text-12-medium opacity-50"
|
|
|
- disabled={props.historyLoading}
|
|
|
- onClick={props.onLoadEarlier}
|
|
|
- >
|
|
|
- {props.historyLoading
|
|
|
- ? language.t("session.messages.loadingEarlier")
|
|
|
- : language.t("session.messages.loadEarlier")}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- <For each={rendered()}>
|
|
|
- {(messageID) => {
|
|
|
- const active = createMemo(() => activeMessageID() === messageID)
|
|
|
- const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
|
|
- equals: (a, b) =>
|
|
|
- a.length === b.length &&
|
|
|
- a.every(
|
|
|
- (c, i) =>
|
|
|
- c.path === b[i].path &&
|
|
|
- c.comment === b[i].comment &&
|
|
|
- c.selection?.startLine === b[i].selection?.startLine &&
|
|
|
- c.selection?.endLine === b[i].selection?.endLine,
|
|
|
- ),
|
|
|
- })
|
|
|
- const commentCount = createMemo(() => comments().length)
|
|
|
- return (
|
|
|
- <div
|
|
|
- id={props.anchor(messageID)}
|
|
|
- data-message-id={messageID}
|
|
|
- classList={{
|
|
|
- "min-w-0 w-full max-w-full": true,
|
|
|
- "md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
|
|
- }}
|
|
|
- style={{
|
|
|
- "content-visibility": active() ? undefined : "auto",
|
|
|
- "contain-intrinsic-size": active() ? undefined : "auto 500px",
|
|
|
- }}
|
|
|
- >
|
|
|
- <Show when={commentCount() > 0}>
|
|
|
- <div class="w-full px-4 md:px-5 pb-2">
|
|
|
- <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
|
|
- <div class="flex w-max min-w-full justify-end gap-2">
|
|
|
- <Index each={comments()}>
|
|
|
- {(commentAccessor: () => MessageComment) => {
|
|
|
- const comment = createMemo(() => commentAccessor())
|
|
|
- return (
|
|
|
- <Show when={comment()}>
|
|
|
- {(c) => (
|
|
|
- <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
|
|
- <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
|
|
- <FileIcon
|
|
|
- node={{ path: c().path, type: "file" }}
|
|
|
- class="size-3.5 shrink-0"
|
|
|
- />
|
|
|
- <span class="truncate">{getFilename(c().path)}</span>
|
|
|
- <Show when={c().selection}>
|
|
|
- {(selection) => (
|
|
|
- <span class="shrink-0 text-text-weak">
|
|
|
- {selection().startLine === selection().endLine
|
|
|
- ? `:${selection().startLine}`
|
|
|
- : `:${selection().startLine}-${selection().endLine}`}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
|
|
- {c().comment}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </Show>
|
|
|
- )
|
|
|
- }}
|
|
|
- </Index>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- <SessionTurn
|
|
|
- sessionID={sessionID() ?? ""}
|
|
|
- messageID={messageID}
|
|
|
- messages={sessionMessages()}
|
|
|
- actions={props.actions}
|
|
|
- active={active()}
|
|
|
- status={active() ? sessionStatus() : undefined}
|
|
|
- showReasoningSummaries={settings.general.showReasoningSummaries()}
|
|
|
- shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
|
|
- editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
|
|
- classes={{
|
|
|
- root: "min-w-0 w-full relative",
|
|
|
- content: "flex flex-col justify-between !overflow-visible",
|
|
|
- container: "w-full px-4 md:px-5",
|
|
|
- }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- )
|
|
|
- }}
|
|
|
- </For>
|
|
|
+ </KobaltePopover.Content>
|
|
|
+ </KobaltePopover.Portal>
|
|
|
+ </KobaltePopover>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </ScrollView>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
+ </Show>
|
|
|
+ <Show when={scrollRoot()}>
|
|
|
+ {(root) => (
|
|
|
+ <Virtualizer
|
|
|
+ data={timelineRowKeys()}
|
|
|
+ cache={virtualCache()}
|
|
|
+ itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
|
|
|
+ scrollRef={root()}
|
|
|
+ shift={props.historyShift}
|
|
|
+ keepMounted={keepMounted()}
|
|
|
+ startMargin={64}
|
|
|
+ ref={(handle) => {
|
|
|
+ if (!handle) {
|
|
|
+ writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
|
|
|
+ virtualizer = undefined
|
|
|
+ return
|
|
|
+ }
|
|
|
+ virtualizer = handle
|
|
|
+ virtualizerSessionKey = cacheSessionKey
|
|
|
+ virtualizerRowKeys = cacheRowKeys
|
|
|
+ maybeAnchorBottom()
|
|
|
+ scheduleContentRoot(root())
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {(key) => <TimelineRowView rowKey={key} />}
|
|
|
+ </Virtualizer>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ </ScrollView>
|
|
|
+ </div>
|
|
|
)
|
|
|
}
|