1593 lines
60 KiB
TypeScript
1593 lines
60 KiB
TypeScript
import {
|
|
createEffect,
|
|
createMemo,
|
|
createSignal,
|
|
For,
|
|
Index,
|
|
on,
|
|
onCleanup,
|
|
onMount,
|
|
Show,
|
|
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 { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
|
|
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"
|
|
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 { 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 { 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 "@/utils/toast"
|
|
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"
|
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
import { useLanguage } from "@/context/language"
|
|
import { useSessionKey } from "@/pages/session/session-layout"
|
|
import { useServerSDK } from "@/context/server-sdk"
|
|
import { usePlatform } from "@/context/platform"
|
|
import { useSettings } from "@/context/settings"
|
|
import { useSDK } from "@/context/sdk"
|
|
import { useSync } from "@/context/sync"
|
|
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
|
import { messageAgentColor } from "@/utils/agent"
|
|
import { sessionTitle } from "@/utils/session-title"
|
|
import { makeTimer } from "@solid-primitives/timer"
|
|
import { scheduleConnectedMeasure } from "./measure"
|
|
import { createTimelineProjection } from "./projection"
|
|
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
|
|
|
const emptyMessages: MessageType[] = []
|
|
const emptyParts: PartType[] = []
|
|
const emptyTools: ToolPart[] = []
|
|
const emptyAssistantMessages: AssistantMessage[] = []
|
|
const idle = { type: "idle" as const }
|
|
|
|
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
|
|
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
|
|
|
|
const timelineFallbackItemSize = 60
|
|
const timelineCache = new Map<
|
|
string,
|
|
{ measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }
|
|
>()
|
|
|
|
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
|
|
const value = part.state.input?.description
|
|
if (typeof value === "string" && value) return value
|
|
}
|
|
|
|
const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900)))
|
|
|
|
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
|
const current = target instanceof Element ? target : undefined
|
|
const nested = current?.closest("[data-scrollable]")
|
|
if (!nested || nested === root) return root
|
|
if (!(nested instanceof HTMLElement)) return root
|
|
return nested
|
|
}
|
|
|
|
const markBoundaryGesture = (input: {
|
|
root: HTMLDivElement
|
|
target: EventTarget | null
|
|
delta: number
|
|
onMarkScrollGesture: (target?: EventTarget | null) => void
|
|
}) => {
|
|
const target = boundaryTarget(input.root, input.target)
|
|
if (target === input.root) {
|
|
input.onMarkScrollGesture(input.root)
|
|
return
|
|
}
|
|
if (
|
|
shouldMarkBoundaryGesture({
|
|
delta: input.delta,
|
|
scrollTop: target.scrollTop,
|
|
scrollHeight: target.scrollHeight,
|
|
clientHeight: target.clientHeight,
|
|
})
|
|
) {
|
|
input.onMarkScrollGesture(input.root)
|
|
}
|
|
}
|
|
|
|
function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean }) {
|
|
const language = useLanguage()
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
|
const language = useLanguage()
|
|
const maxFiles = 10
|
|
const [state, setState] = createStore({
|
|
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)))
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
|
const fileComponent = useFileComponent()
|
|
const view = normalize(props.diff)
|
|
|
|
return (
|
|
<div data-slot="session-turn-diff-view" data-scrollable>
|
|
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function MessageTimeline(props: {
|
|
actions?: UserActions
|
|
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
|
onResumeScroll: () => void
|
|
setScrollRef: (el: HTMLDivElement | undefined) => void
|
|
onScheduleScrollState: (el: HTMLDivElement) => void
|
|
onAutoScrollHandleScroll: () => void
|
|
onMarkScrollGesture: (target?: EventTarget | null) => void
|
|
hasScrollGesture: () => boolean
|
|
onUserScroll: () => void
|
|
onHistoryScroll: () => void
|
|
onAutoScrollInteraction: (event: MouseEvent) => void
|
|
shouldAnchorBottom: () => boolean
|
|
centered: boolean
|
|
setContentRef: (el: HTMLDivElement) => void
|
|
userMessages: UserMessage[]
|
|
anchor: (id: string) => string
|
|
setRevealMessage?: (fn: (id: string) => void) => void
|
|
setScrollToEnd?: (fn: () => void) => void
|
|
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
|
|
}) {
|
|
let touchGesture: number | undefined
|
|
|
|
const navigate = useNavigate()
|
|
const serverSDK = useServerSDK()
|
|
const sdk = useSDK()
|
|
const sync = useSync()
|
|
const settings = useSettings()
|
|
const dialog = useDialog()
|
|
const language = useLanguage()
|
|
const { params, sessionKey } = useSessionKey()
|
|
const ownerSessionKey = sessionKey()
|
|
const cached = timelineCache.get(ownerSessionKey)
|
|
const initialMeasurements = cached?.measurements
|
|
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
|
|
const platform = usePlatform()
|
|
|
|
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
|
const sessionID = createMemo(() => params.id)
|
|
const sessionStatus = createMemo(() => {
|
|
const id = sessionID()
|
|
if (!id) return idle
|
|
return sync().data.session_status[id] ?? idle
|
|
})
|
|
const working = createMemo(() => sessionStatus().type !== "idle")
|
|
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
|
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
|
|
|
|
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
|
|
|
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
|
|
if (working()) return "showing"
|
|
if (prev === "showing" || !timeoutDone()) return "hiding"
|
|
return "hidden"
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (workingStatus() !== "hiding") return
|
|
|
|
setTimeoutDone(false)
|
|
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
|
|
})
|
|
|
|
const info = createMemo(() => {
|
|
const id = sessionID()
|
|
if (!id) return
|
|
return sync().session.get(id)
|
|
})
|
|
const titleValue = createMemo(() => info()?.title)
|
|
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
|
const shareUrl = createMemo(() => info()?.share?.url)
|
|
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
|
const parentID = createMemo(() => info()?.parentID)
|
|
const parent = createMemo(() => {
|
|
const id = parentID()
|
|
if (!id) return
|
|
return sync().session.get(id)
|
|
})
|
|
const parentMessages = createMemo(() => {
|
|
const id = parentID()
|
|
if (!id) return emptyMessages
|
|
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 getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
|
const childTaskDescription = createMemo(() => {
|
|
const id = sessionID()
|
|
if (!id) return
|
|
return parentMessages()
|
|
.flatMap((message) => getMsgParts(message.id))
|
|
.map((part) => taskDescription(part, id))
|
|
.findLast((value): value is string => !!value)
|
|
})
|
|
const childTitle = createMemo(() => {
|
|
if (!parentID()) return titleLabel() ?? ""
|
|
if (childTaskDescription()) return childTaskDescription()
|
|
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
|
|
if (value) return value
|
|
return language.t("command.session.new")
|
|
})
|
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
|
const projection = createTimelineProjection({
|
|
messages: sessionMessages,
|
|
userMessages: () => props.userMessages,
|
|
parts: getMsgParts,
|
|
status: sessionStatus,
|
|
showReasoningSummaries: settings.general.showReasoningSummaries,
|
|
})
|
|
const activeMessageID = projection.activeMessageID
|
|
const assistantMessagesByParent = projection.assistantMessagesByParent
|
|
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
|
const messageByID = projection.messageByID
|
|
const messageLastRowIndex = projection.messageLastRowIndex
|
|
const messageRowIndex = projection.messageRowIndex
|
|
const timelineRowByKey = projection.rowByKey
|
|
const timelineRows = projection.rows
|
|
|
|
let prependAnchor: { key: string; offset: number } | undefined
|
|
let prependAnchorFrame: number | undefined
|
|
let prependLoading = false
|
|
const clearPrependAnchor = () => {
|
|
prependLoading = false
|
|
prependAnchor = undefined
|
|
if (prependAnchorFrame === undefined) return
|
|
cancelAnimationFrame(prependAnchorFrame)
|
|
prependAnchorFrame = undefined
|
|
}
|
|
const capturePrependAnchor = () => {
|
|
prependLoading = true
|
|
updatePrependAnchor()
|
|
}
|
|
const updatePrependAnchor = () => {
|
|
const root = listRoot()
|
|
if (!root) return
|
|
const view = root.getBoundingClientRect()
|
|
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
|
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
|
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
|
|
.sort((a, b) => a.rect.top - b.rect.top)[0]
|
|
if (!anchor) return
|
|
if (!anchor.element.dataset.timelineKey) return
|
|
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
|
|
}
|
|
const restorePrependAnchor = (done: boolean) => {
|
|
if (done) prependLoading = false
|
|
applyPrependAnchor()
|
|
}
|
|
const applyPrependAnchor = () => {
|
|
const root = listRoot()
|
|
if (!root || !prependAnchor) return
|
|
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
|
let frames = 0
|
|
let stable = 0
|
|
const apply = () => {
|
|
prependAnchorFrame = undefined
|
|
const anchor = prependAnchor
|
|
if (!anchor) return
|
|
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
|
|
const delta = element
|
|
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
|
|
: undefined
|
|
if (delta !== undefined && Math.abs(delta) > 0.5) {
|
|
root.scrollTop += delta
|
|
stable = 0
|
|
} else {
|
|
stable += 1
|
|
}
|
|
frames += 1
|
|
if (stable >= 30 || frames >= 180) {
|
|
if (!prependLoading) prependAnchor = undefined
|
|
return
|
|
}
|
|
prependAnchorFrame = requestAnimationFrame(apply)
|
|
}
|
|
prependAnchorFrame = requestAnimationFrame(apply)
|
|
}
|
|
|
|
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
|
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
|
|
let resizePinnedIndexes: number[] = []
|
|
let resizePinFrame: number | undefined
|
|
let virtualContent: HTMLDivElement | undefined
|
|
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
|
get count() {
|
|
return timelineRows().length
|
|
},
|
|
getScrollElement: () => listRoot() ?? null,
|
|
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
|
initialMeasurementsCache: initialMeasurements,
|
|
estimateSize: () => timelineFallbackItemSize,
|
|
scrollToFn: (offset, options, instance) => {
|
|
// Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height.
|
|
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
|
elementScroll(offset, options, instance)
|
|
},
|
|
get getItemKey() {
|
|
const rows = timelineRows()
|
|
return (index: number) => {
|
|
const row = rows[index]
|
|
// ResizeObserver can report a removed element after its row has left the projection.
|
|
if (!row) return `removed:${index}`
|
|
return TimelineRow.key(row)
|
|
}
|
|
},
|
|
anchorTo: "end",
|
|
followOnAppend: true,
|
|
scrollEndThreshold: 80,
|
|
get scrollMargin() {
|
|
return showHeader() ? 64 : 0
|
|
},
|
|
overscan: 50,
|
|
paddingEnd: 64,
|
|
rangeExtractor: (range) => {
|
|
const id = activeMessageID()
|
|
const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1
|
|
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
|
|
return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
|
|
},
|
|
})
|
|
const resizeItem = virtualizer.resizeItem
|
|
virtualizer.resizeItem = (index, size) => {
|
|
const item = virtualizer.measurementsCache[index]
|
|
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
|
|
const root = listRoot()
|
|
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
|
|
const view = root.getBoundingClientRect()
|
|
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
|
|
.filter((element) => {
|
|
const rect = element.getBoundingClientRect()
|
|
return rect.bottom > view.top && rect.top < view.bottom
|
|
})
|
|
.map((element) => Number(element.dataset.index))
|
|
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
|
resizePinFrame = requestAnimationFrame(() => {
|
|
resizePinFrame = requestAnimationFrame(() => {
|
|
resizePinFrame = undefined
|
|
resizePinnedIndexes = []
|
|
})
|
|
})
|
|
}
|
|
resizeItem(index, size)
|
|
}
|
|
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
|
|
item.end <= instance.getLogicalScrollOffset()
|
|
const virtualItemByKey = createMemo(
|
|
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
|
)
|
|
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string))
|
|
createEffect(() => {
|
|
props.setRevealMessage?.((id) => {
|
|
const index = messageRowIndex().get(id)
|
|
if (index === undefined) return
|
|
virtualizer.scrollToIndex(index, { align: "center" })
|
|
})
|
|
props.setScrollToEnd?.(() => virtualizer.scrollToEnd())
|
|
props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
|
|
})
|
|
|
|
let overscanFrame: number | undefined
|
|
onMount(() => {
|
|
overscanFrame = requestAnimationFrame(() => {
|
|
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
|
overscanFrame = requestAnimationFrame(() => {
|
|
overscanFrame = undefined
|
|
if (renderOverscan() < 20) setRenderOverscan(20)
|
|
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
|
})
|
|
})
|
|
})
|
|
|
|
let bottomAnchorSessionKey = ""
|
|
let bottomAnchorFrame: number | undefined
|
|
|
|
const maybeAnchorBottom = () => {
|
|
const key = sessionKey()
|
|
if (bottomAnchorSessionKey === key) return
|
|
if (timelineRows().length === 0) return
|
|
bottomAnchorSessionKey = key
|
|
if (!props.shouldAnchorBottom()) return
|
|
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
|
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
|
clearPrependAnchor()
|
|
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
|
bottomAnchorFrame = requestAnimationFrame(() => {
|
|
bottomAnchorFrame = undefined
|
|
if (sessionKey() !== key) return
|
|
virtualizer.scrollToEnd()
|
|
})
|
|
}
|
|
|
|
let measuredSessionKey = sessionKey()
|
|
createEffect(() => {
|
|
const key = sessionKey()
|
|
timelineRows().length
|
|
if (measuredSessionKey !== key) {
|
|
measuredSessionKey = key
|
|
virtualizer.measure()
|
|
}
|
|
maybeAnchorBottom()
|
|
})
|
|
|
|
onCleanup(() => {
|
|
clearPrependAnchor()
|
|
timelineCache.delete(ownerSessionKey)
|
|
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
|
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
|
|
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
|
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
|
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
|
|
props.setRevealMessage?.(() => {})
|
|
props.setScrollToEnd?.(() => {})
|
|
props.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
|
|
})
|
|
|
|
const [title, setTitle] = createStore({
|
|
draft: "",
|
|
editing: false,
|
|
menuOpen: false,
|
|
pendingRename: false,
|
|
pendingShare: false,
|
|
})
|
|
let titleRef: HTMLInputElement | undefined
|
|
|
|
const [share, setShare] = createStore({
|
|
open: false,
|
|
dismiss: null as "escape" | "outside" | null,
|
|
})
|
|
const [bar, setBar] = createStore({
|
|
ms: pace(640),
|
|
})
|
|
let more: HTMLButtonElement | undefined
|
|
let head: HTMLDivElement | undefined
|
|
|
|
const updateTitleMetrics = () => {
|
|
if (!head || head.clientWidth <= 0) return
|
|
setBar("ms", pace(head.clientWidth))
|
|
}
|
|
|
|
createResizeObserver(() => head, updateTitleMetrics)
|
|
|
|
const bindListRoot = (root: HTMLDivElement) => {
|
|
if (root === listRoot()) return
|
|
setListRoot(root)
|
|
props.setScrollRef(root)
|
|
}
|
|
|
|
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
|
if (!prependLoading) clearPrependAnchor()
|
|
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) => {
|
|
if (!prependLoading) clearPrependAnchor()
|
|
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 (!prependLoading) clearPrependAnchor()
|
|
if (event.target !== event.currentTarget) return
|
|
props.onMarkScrollGesture(event.currentTarget)
|
|
}
|
|
|
|
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
if (prependLoading) updatePrependAnchor()
|
|
props.onScheduleScrollState(event.currentTarget)
|
|
props.onHistoryScroll()
|
|
if (!props.hasScrollGesture()) return
|
|
props.onUserScroll()
|
|
props.onAutoScrollHandleScroll()
|
|
props.onMarkScrollGesture(event.currentTarget)
|
|
}
|
|
|
|
onCleanup(() => {
|
|
props.setScrollRef(undefined)
|
|
})
|
|
|
|
const viewShare = () => {
|
|
const url = shareUrl()
|
|
if (!url) return
|
|
platform.openLink(url)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
const shareMutation = useMutation(() => ({
|
|
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id, directory: sdk().directory }),
|
|
onError: (err) => {
|
|
console.error("Failed to share session", err)
|
|
},
|
|
}))
|
|
|
|
const unshareMutation = useMutation(() => ({
|
|
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id, directory: sdk().directory }),
|
|
onError: (err) => {
|
|
console.error("Failed to unshare session", err)
|
|
},
|
|
}))
|
|
|
|
const titleMutation = useMutation(() => ({
|
|
mutationFn: (input: { id: string; title: string }) =>
|
|
sdk().client.session.update({ sessionID: input.id, title: input.title }),
|
|
onSuccess: (_, input) => {
|
|
sync().set(
|
|
produce((draft) => {
|
|
const index = draft.session.findIndex((s) => s.id === input.id)
|
|
if (index !== -1) draft.session[index].title = input.title
|
|
}),
|
|
)
|
|
setTitle("editing", false)
|
|
},
|
|
onError: (err) => {
|
|
showToast({
|
|
title: language.t("common.requestFailed"),
|
|
description: errorMessage(err),
|
|
})
|
|
},
|
|
}))
|
|
|
|
const shareSession = () => {
|
|
const id = sessionID()
|
|
if (!id || shareMutation.isPending) return
|
|
if (!shareEnabled()) return
|
|
shareMutation.mutate(id)
|
|
}
|
|
|
|
const unshareSession = () => {
|
|
const id = sessionID()
|
|
if (!id || unshareMutation.isPending) return
|
|
if (!shareEnabled()) return
|
|
unshareMutation.mutate(id)
|
|
}
|
|
|
|
createEffect(
|
|
on(
|
|
sessionKey,
|
|
() =>
|
|
setTitle({
|
|
draft: "",
|
|
editing: false,
|
|
menuOpen: false,
|
|
pendingRename: false,
|
|
pendingShare: false,
|
|
}),
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
createEffect(
|
|
on(
|
|
() => [parentID(), childTaskDescription()] as const,
|
|
([id, description]) => {
|
|
if (!id || description) return
|
|
if (sync().data.message[id] !== undefined) return
|
|
void sync().session.sync(id)
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
const openTitleEditor = () => {
|
|
if (!sessionID() || parentID()) return
|
|
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
|
requestAnimationFrame(() => {
|
|
titleRef?.focus()
|
|
titleRef?.select()
|
|
})
|
|
}
|
|
|
|
const closeTitleEditor = () => {
|
|
if (titleMutation.isPending) return
|
|
setTitle("editing", false)
|
|
}
|
|
|
|
const saveTitleEditor = () => {
|
|
const id = sessionID()
|
|
if (!id) return
|
|
if (titleMutation.isPending) return
|
|
|
|
const next = title.draft.trim()
|
|
if (!next || next === (titleLabel() ?? "")) {
|
|
setTitle("editing", false)
|
|
return
|
|
}
|
|
|
|
titleMutation.mutate({ id, title: next })
|
|
}
|
|
|
|
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)
|
|
}),
|
|
)
|
|
sync().session.evict(sessionID)
|
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
|
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
|
|
})
|
|
.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
|
|
|
|
const removed = new Set<string>([sessionID])
|
|
const byParent = new Map<string, string[]>()
|
|
for (const item of sync().data.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)
|
|
}
|
|
}
|
|
|
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
|
|
|
sync().set(
|
|
produce((draft) => {
|
|
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
|
}),
|
|
)
|
|
|
|
for (const id of removed) {
|
|
sync().session.evict(id)
|
|
}
|
|
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
|
|
return true
|
|
}
|
|
|
|
const navigateParent = () => {
|
|
const id = parentID()
|
|
if (!id) return
|
|
navigate(`/${params.dir}/session/${id}`)
|
|
}
|
|
|
|
function DialogDeleteSession(props: { sessionID: string }) {
|
|
const name = createMemo(
|
|
() => sessionTitle(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 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 renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>, onSizeChange?: () => void) => {
|
|
if (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) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
|
|
}
|
|
onSizeChange={onSizeChange}
|
|
/>
|
|
)
|
|
}
|
|
|
|
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)
|
|
})
|
|
const defaultOpen = createMemo(() => {
|
|
const item = part()
|
|
if (!item) return
|
|
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
|
|
})
|
|
|
|
return (
|
|
<Show when={message()}>
|
|
{(message) => (
|
|
<Show when={part()}>
|
|
{(part) => (
|
|
<MessagePart
|
|
part={part()}
|
|
message={message()}
|
|
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
|
turnDurationMs={turnDurationMs(row().userMessageID)}
|
|
defaultOpen={defaultOpen()}
|
|
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
|
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
|
deferToolContent
|
|
virtualizeDiff={false}
|
|
onContentRendered={onSizeChange}
|
|
/>
|
|
)}
|
|
</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 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-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>, onSizeChange?: () => void) => {
|
|
switch (row()._tag) {
|
|
case "TurnGap":
|
|
return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
|
|
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, onSizeChange)}
|
|
</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>
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function TimelineRowView(props: { row: TimelineRow.TimelineRow; onSizeChange?: () => void }) {
|
|
return renderTimelineRow(() => props.row, props.onSizeChange)
|
|
}
|
|
|
|
function VirtualTimelineRow(props: { rowKey: string }) {
|
|
let element: HTMLDivElement
|
|
const initialItem = virtualItemByKey().get(props.rowKey)!
|
|
const initialRow = timelineRowByKey().get(props.rowKey)!
|
|
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
|
|
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
|
|
const asyncFile = () => {
|
|
const value = row()
|
|
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
|
|
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
|
|
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool)
|
|
}
|
|
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
|
|
let contentMeasureFrame: number | undefined
|
|
|
|
onMount(() => virtualizer.measureElement(element))
|
|
|
|
createEffect(
|
|
on(
|
|
() => item().index,
|
|
() => {
|
|
virtualizer.measureElement(element)
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
onCleanup(() => {
|
|
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
|
})
|
|
|
|
return (
|
|
<div
|
|
data-timeline-key={props.rowKey}
|
|
style={{
|
|
position: "absolute",
|
|
top: `${item().start - (showHeader() ? 64 : 0)}px`,
|
|
left: "0",
|
|
width: "100%",
|
|
height: `${item().size}px`,
|
|
overflow: "clip",
|
|
}}
|
|
>
|
|
<div
|
|
ref={(value) => {
|
|
element = value
|
|
}}
|
|
data-index={item().index}
|
|
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
|
|
>
|
|
<TimelineRowView
|
|
row={row()}
|
|
onSizeChange={() => {
|
|
setReady(true)
|
|
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
|
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<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}
|
|
>
|
|
<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={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={{
|
|
"--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 data-component="session-progress" data-state={workingStatus()} aria-hidden="true">
|
|
<div
|
|
data-component="session-progress-bar"
|
|
style={{
|
|
background: tint() ?? "var(--icon-interactive-base)",
|
|
animation: `session-progress-whip ${bar.ms}ms infinite`,
|
|
}}
|
|
/>
|
|
</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
|
|
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"
|
|
>
|
|
<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>
|
|
</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>
|
|
}
|
|
>
|
|
<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()
|
|
setTitle("pendingRename", false)
|
|
openTitleEditor()
|
|
return
|
|
}
|
|
if (title.pendingShare) {
|
|
event.preventDefault()
|
|
requestAnimationFrame(() => {
|
|
setShare({ open: true, dismiss: null })
|
|
setTitle("pendingShare", 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)
|
|
}}
|
|
>
|
|
<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 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>
|
|
</div>
|
|
</div>
|
|
</KobaltePopover.Content>
|
|
</KobaltePopover.Portal>
|
|
</KobaltePopover>
|
|
</Show>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
<div
|
|
data-timeline-virtual-content
|
|
ref={(element) => {
|
|
virtualContent = element
|
|
props.setContentRef(element)
|
|
}}
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
position: "relative",
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<For each={virtualRowKeys()}>{(rowKey) => <VirtualTimelineRow rowKey={rowKey} />}</For>
|
|
<Show when={timelineRows().length > 0}>
|
|
<div
|
|
data-timeline-row="bottom-spacer"
|
|
aria-hidden="true"
|
|
class="h-16 absolute top-0 left-0 w-full"
|
|
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
</ScrollView>
|
|
</div>
|
|
)
|
|
}
|