Compare commits
4 Commits
v0.13.3-de
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403a3ff189 | ||
|
|
7996e514c4 | ||
|
|
141be2cde0 | ||
|
|
259d457209 |
@@ -539,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
|
|||||||
@@ -963,6 +963,7 @@ impl CliEntry {
|
|||||||
"--auth-cookie-name".to_string(),
|
"--auth-cookie-name".to_string(),
|
||||||
auth_cookie_name.to_string(),
|
auth_cookie_name.to_string(),
|
||||||
"--generate-token".to_string(),
|
"--generate-token".to_string(),
|
||||||
|
"--unrestricted-root".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
|
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
|
||||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
@@ -16,6 +16,7 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
function DeleteUpToIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
|
|||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||||
|
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||||
|
|
||||||
@@ -803,19 +805,19 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
data-message-id={resolvedBlock().record.id}
|
data-message-id={resolvedBlock().record.id}
|
||||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<For each={resolvedBlock().items}>
|
<Index each={resolvedBlock().items}>
|
||||||
{(item, index) => (
|
{(item, index) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={item.type === "content"}>
|
<Match when={item().type === "content"}>
|
||||||
<MessageContentItem
|
<MessageContentItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={(item as ContentDisplayItem).messageId}
|
messageId={(item() as ContentDisplayItem).messageId}
|
||||||
startPartId={(item as ContentDisplayItem).startPartId}
|
startPartId={(item() as ContentDisplayItem).startPartId}
|
||||||
messageIndex={props.messageIndex}
|
messageIndex={props.messageIndex}
|
||||||
lastAssistantIndex={props.lastAssistantIndex}
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -825,18 +827,18 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "tool"}>
|
<Match when={item().type === "tool"}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const toolItem = item as ToolDisplayItem
|
const toolItem = item() as ToolDisplayItem
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-message" data-key={toolItem.key}>
|
<div class="tool-call-message" data-key={toolItem.key}>
|
||||||
<ToolCallItem
|
<ToolCallItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={toolItem.messageId}
|
messageId={toolItem.messageId}
|
||||||
partId={toolItem.partId}
|
partId={toolItem.partId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
deleteHover={props.deleteHover}
|
deleteHover={props.deleteHover}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -849,13 +851,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-start"}>
|
<Match when={item().type === "step-start"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="start"
|
kind="start"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showAgentMeta
|
showAgentMeta
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -865,14 +867,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-finish"}>
|
<Match when={item().type === "step-finish"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="finish"
|
kind="finish"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showUsage={props.showUsageMetrics()}
|
showUsage={props.showUsageMetrics()}
|
||||||
borderColor={(item as StepDisplayItem).accentColor}
|
borderColor={(item() as StepDisplayItem).accentColor}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -882,31 +884,31 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "compaction"}>
|
<Match when={item().type === "compaction"}>
|
||||||
<CompactionCard
|
<CompactionCard
|
||||||
part={(item as CompactionDisplayItem).part}
|
part={(item() as CompactionDisplayItem).part}
|
||||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
messageInfo={(item() as CompactionDisplayItem).messageInfo}
|
||||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
borderColor={(item() as CompactionDisplayItem).accentColor}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as CompactionDisplayItem).messageId}
|
messageId={(item() as CompactionDisplayItem).messageId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "reasoning"}>
|
<Match when={item().type === "reasoning"}>
|
||||||
<ReasoningCard
|
<ReasoningCard
|
||||||
part={(item as ReasoningDisplayItem).part}
|
part={(item() as ReasoningDisplayItem).part}
|
||||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as ReasoningDisplayItem).messageId}
|
messageId={(item() as ReasoningDisplayItem).messageId}
|
||||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
|
||||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
@@ -916,7 +918,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1293,14 +1295,23 @@ interface ReasoningCardProps {
|
|||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningStreamOutput(props: {
|
||||||
const { t } = useI18n()
|
text: Accessor<string>
|
||||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
scrollTopSnapshot: Accessor<number>
|
||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
setScrollTopSnapshot: (next: number) => void
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
onContentRendered?: () => void
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
ariaLabel: string
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
let pendingRenderNotificationFrame: number | null = null
|
let pendingRenderNotificationFrame: number | null = null
|
||||||
|
|
||||||
|
const followScroll = createFollowScroll({
|
||||||
|
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||||
|
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||||
|
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
|
||||||
|
sentinelClassName: "reasoning-scroll-sentinel",
|
||||||
|
})
|
||||||
|
|
||||||
const notifyContentRendered = () => {
|
const notifyContentRendered = () => {
|
||||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
if (pendingRenderNotificationFrame !== null) {
|
||||||
@@ -1312,6 +1323,17 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const nextText = props.text()
|
||||||
|
if (preRef && preRef.textContent !== nextText) {
|
||||||
|
preRef.textContent = nextText
|
||||||
|
}
|
||||||
|
if (followScroll.autoScroll()) {
|
||||||
|
followScroll.restoreAfterRender({ forceBottom: true })
|
||||||
|
}
|
||||||
|
notifyContentRendered()
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
if (pendingRenderNotificationFrame !== null) {
|
||||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||||
@@ -1319,6 +1341,37 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={followScroll.registerContainer}
|
||||||
|
class="message-reasoning-output"
|
||||||
|
role="region"
|
||||||
|
aria-label={props.ariaLabel}
|
||||||
|
onScroll={followScroll.handleScroll}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
ref={(element) => {
|
||||||
|
preRef = element || undefined
|
||||||
|
if (preRef) {
|
||||||
|
preRef.textContent = props.text() || ""
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="message-reasoning-text"
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
{followScroll.renderSentinel()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasoningCard(props: ReasoningCardProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||||
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
|
||||||
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1393,12 +1446,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!expanded()) return
|
|
||||||
reasoningText()
|
|
||||||
notifyContentRendered()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
|
|
||||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
@@ -1553,9 +1600,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
<ReasoningStreamOutput
|
||||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
text={reasoningText}
|
||||||
</div>
|
scrollTopSnapshot={scrollTopSnapshot}
|
||||||
|
setScrollTopSnapshot={setScrollTopSnapshot}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
|
||||||
|
|
||||||
const lastCompactionIndex = createMemo(() => {
|
const lastCompactionIndex = createMemo(() => {
|
||||||
// Depend on a single session revision signal (not every message/part read)
|
// Depend on a single session revision signal (not every message/part read)
|
||||||
// to keep reactive overhead small.
|
// to keep reactive overhead small.
|
||||||
@@ -315,15 +317,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastAssistantIndex = createMemo(() => {
|
const lastAssistantIndex = createMemo(() => {
|
||||||
const ids = messageIds()
|
const messageId = lastAssistantMessageId()
|
||||||
const resolvedStore = store()
|
if (!messageId) return -1
|
||||||
for (let index = ids.length - 1; index >= 0; index--) {
|
return messageIndexById().get(messageId) ?? -1
|
||||||
const record = resolvedStore.getMessage(ids[index])
|
|
||||||
if (record?.role === "assistant") {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||||
@@ -734,88 +730,93 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const loading = Boolean(props.loading)
|
const loading = Boolean(props.loading)
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
|
|
||||||
if (loading) {
|
// Wrap all iteration of the store-proxied `ids` array in untrack()
|
||||||
handleClearTimelineSelection()
|
// to prevent O(n) per-element reactive subscriptions. The effect
|
||||||
previousTimelineIds = []
|
// only needs to re-run when `messageIds` (memo) changes.
|
||||||
setTimelineSegments([])
|
untrack(() => {
|
||||||
seenTimelineMessageIds.clear()
|
if (loading) {
|
||||||
seenTimelineSegmentKeys.clear()
|
handleClearTimelineSelection()
|
||||||
timelinePartCountsByMessageId.clear()
|
previousTimelineIds = []
|
||||||
pendingTimelineMessagePartUpdates.clear()
|
setTimelineSegments([])
|
||||||
if (pendingTimelinePartUpdateFrame !== null) {
|
seenTimelineMessageIds.clear()
|
||||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
seenTimelineSegmentKeys.clear()
|
||||||
pendingTimelinePartUpdateFrame = null
|
timelinePartCountsByMessageId.clear()
|
||||||
}
|
pendingTimelineMessagePartUpdates.clear()
|
||||||
return
|
if (pendingTimelinePartUpdateFrame !== null) {
|
||||||
}
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
|
||||||
seedTimeline()
|
|
||||||
previousTimelineIds = ids.slice()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.length < previousTimelineIds.length) {
|
|
||||||
seedTimeline()
|
|
||||||
previousTimelineIds = ids.slice()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.length === previousTimelineIds.length) {
|
|
||||||
let changedIndex = -1
|
|
||||||
let changeCount = 0
|
|
||||||
for (let index = 0; index < ids.length; index++) {
|
|
||||||
if (ids[index] !== previousTimelineIds[index]) {
|
|
||||||
changedIndex = index
|
|
||||||
changeCount += 1
|
|
||||||
if (changeCount > 1) break
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (changeCount === 1 && changedIndex >= 0) {
|
|
||||||
const oldId = previousTimelineIds[changedIndex]
|
|
||||||
const newId = ids[changedIndex]
|
|
||||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
|
||||||
seenTimelineMessageIds.delete(oldId)
|
|
||||||
seenTimelineMessageIds.add(newId)
|
|
||||||
setTimelineSegments((prev) => {
|
|
||||||
const next = prev.map((segment) => {
|
|
||||||
if (segment.messageId !== oldId) return segment
|
|
||||||
const updatedId = segment.id.replace(oldId, newId)
|
|
||||||
return { ...segment, messageId: newId, id: updatedId }
|
|
||||||
})
|
|
||||||
seenTimelineSegmentKeys.clear()
|
|
||||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keep part count tracking in sync with id replacement.
|
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
seedTimeline()
|
||||||
if (existingPartCount !== undefined) {
|
previousTimelineIds = [...ids]
|
||||||
timelinePartCountsByMessageId.delete(oldId)
|
return
|
||||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
}
|
||||||
|
|
||||||
|
if (ids.length < previousTimelineIds.length) {
|
||||||
|
seedTimeline()
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === previousTimelineIds.length) {
|
||||||
|
let changedIndex = -1
|
||||||
|
let changeCount = 0
|
||||||
|
for (let index = 0; index < ids.length; index++) {
|
||||||
|
if (ids[index] !== previousTimelineIds[index]) {
|
||||||
|
changedIndex = index
|
||||||
|
changeCount += 1
|
||||||
|
if (changeCount > 1) break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (changeCount === 1 && changedIndex >= 0) {
|
||||||
|
const oldId = previousTimelineIds[changedIndex]
|
||||||
|
const newId = ids[changedIndex]
|
||||||
|
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||||
|
seenTimelineMessageIds.delete(oldId)
|
||||||
|
seenTimelineMessageIds.add(newId)
|
||||||
|
setTimelineSegments((prev) => {
|
||||||
|
const next = prev.map((segment) => {
|
||||||
|
if (segment.messageId !== oldId) return segment
|
||||||
|
const updatedId = segment.id.replace(oldId, newId)
|
||||||
|
return { ...segment, messageId: newId, id: updatedId }
|
||||||
|
})
|
||||||
|
seenTimelineSegmentKeys.clear()
|
||||||
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
// Keep part count tracking in sync with id replacement.
|
||||||
return
|
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||||
|
if (existingPartCount !== undefined) {
|
||||||
|
timelinePartCountsByMessageId.delete(oldId)
|
||||||
|
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const newIds: string[] = []
|
const newIds: string[] = []
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
if (!seenTimelineMessageIds.has(id)) {
|
if (!seenTimelineMessageIds.has(id)) {
|
||||||
newIds.push(id)
|
newIds.push(id)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (newIds.length > 0) {
|
|
||||||
newIds.forEach((id) => {
|
|
||||||
seenTimelineMessageIds.add(id)
|
|
||||||
appendTimelineForMessage(id)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
if (newIds.length > 0) {
|
||||||
|
newIds.forEach((id) => {
|
||||||
|
seenTimelineMessageIds.add(id)
|
||||||
|
appendTimelineForMessage(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearPendingTimelinePartUpdateFrame() {
|
function clearPendingTimelinePartUpdateFrame() {
|
||||||
@@ -886,36 +887,49 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.loading) return
|
if (props.loading) return
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
const resolvedStore = store()
|
// Also re-run when sessionRevision bumps (covers part additions within
|
||||||
|
// existing messages) but read individual records inside untrack() to
|
||||||
|
// avoid creating O(n) fine-grained subscriptions.
|
||||||
|
sessionRevision()
|
||||||
|
|
||||||
let hasChanges = false
|
// Wrap the iteration in untrack() so that accessing individual elements
|
||||||
for (const messageId of ids) {
|
// of the store-proxied `ids` array does not create O(n) per-element
|
||||||
const record = resolvedStore.getMessage(messageId)
|
// reactive subscriptions. We only need to re-run when the memo
|
||||||
const partCount = record?.partIds.length ?? 0
|
// (messageIds) or sessionRevision changes — not per-element.
|
||||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
untrack(() => {
|
||||||
|
const resolvedStore = store()
|
||||||
|
const idsSet = new Set(ids)
|
||||||
|
let hasChanges = false
|
||||||
|
|
||||||
if (previousCount === undefined) {
|
for (const messageId of ids) {
|
||||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
continue
|
const partCount = record?.partIds.length ?? 0
|
||||||
|
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||||
|
|
||||||
|
if (previousCount === undefined) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousCount !== partCount) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
pendingTimelineMessagePartUpdates.add(messageId)
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousCount !== partCount) {
|
// Drop tracking for ids that are no longer present.
|
||||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
|
||||||
pendingTimelineMessagePartUpdates.add(messageId)
|
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||||
hasChanges = true
|
if (!idsSet.has(trackedId)) {
|
||||||
|
timelinePartCountsByMessageId.delete(trackedId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Drop tracking for ids that are no longer present.
|
if (hasChanges) {
|
||||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
scheduleTimelinePartUpdateFlush()
|
||||||
if (!ids.includes(trackedId)) {
|
|
||||||
timelinePartCountsByMessageId.delete(trackedId)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
scheduleTimelinePartUpdateFlush()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -540,6 +540,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
mode={pickerMode()}
|
mode={pickerMode()}
|
||||||
onClose={handlePickerClose}
|
onClose={handlePickerClose}
|
||||||
onSelect={handlePickerSelect}
|
onSelect={handlePickerSelect}
|
||||||
|
onSubmitWithoutSelection={() => {
|
||||||
|
handlePickerClose()
|
||||||
|
void handleSend()
|
||||||
|
}}
|
||||||
agents={instanceAgents()}
|
agents={instanceAgents()}
|
||||||
commands={getCommands(props.instanceId)}
|
commands={getCommands(props.instanceId)}
|
||||||
instanceClient={instance()!.client}
|
instanceClient={instance()!.client}
|
||||||
|
|||||||
@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pickerMode() === "mention" && pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
|
|
||||||
// Remove the partial @mention text from the textarea when ESC is pressed
|
|
||||||
const textarea = options.getTextarea()
|
|
||||||
if (textarea) {
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const cursorPos = textarea.selectionStart
|
|
||||||
// Remove text from @ position to cursor position
|
|
||||||
const before = currentPrompt.substring(0, pos)
|
|
||||||
const after = currentPrompt.substring(cursorPos)
|
|
||||||
options.setPrompt(before + after)
|
|
||||||
|
|
||||||
// Restore cursor position to where @ was
|
|
||||||
setTimeout(() => {
|
|
||||||
const nextTextarea = options.getTextarea()
|
|
||||||
if (nextTextarea) {
|
|
||||||
nextTextarea.setSelectionRange(pos, pos)
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
// Clear ignoredAtPositions so typing @ again will work
|
|
||||||
setIgnoredAtPositions(new Set<number>())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||||
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
||||||
import { stringify as stringifyYaml } from "yaml"
|
import { stringify as stringifyYaml } from "yaml"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
|
|||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
|||||||
|
|
||||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
|
||||||
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
|
||||||
|
|
||||||
function makeRenderCacheKey(
|
function makeRenderCacheKey(
|
||||||
toolCallId?: string | null,
|
toolCallId?: string | null,
|
||||||
@@ -82,6 +81,27 @@ interface ToolCallProps {
|
|||||||
forceCollapsed?: boolean
|
forceCollapsed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ToolStatusIndicator(props: { status: Accessor<string> }) {
|
||||||
|
const isVisible = (value: string) => props.status() === value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
|
||||||
|
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
|
||||||
|
<Hourglass class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
|
||||||
|
<XCircle class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ToolCallDetails(props: {
|
function ToolCallDetails(props: {
|
||||||
toolCallMemo: () => ToolCallPart
|
toolCallMemo: () => ToolCallPart
|
||||||
toolState: () => ToolState | undefined
|
toolState: () => ToolState | undefined
|
||||||
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
|
|||||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
const followScroll = createFollowScroll({
|
||||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
|
||||||
|
sentinelClassName: "tool-call-scroll-sentinel",
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
})
|
||||||
let detachScrollIntentListeners: (() => void) | undefined
|
|
||||||
|
|
||||||
let pendingScrollFrame: number | null = null
|
|
||||||
let pendingAnchorScroll: number | null = null
|
|
||||||
let userScrollIntentUntil = 0
|
|
||||||
let lastKnownScrollTop = props.scrollTopSnapshot()
|
|
||||||
|
|
||||||
function restoreScrollPosition(forceBottom = false) {
|
|
||||||
const container = scrollContainerRef
|
|
||||||
if (!container) return
|
|
||||||
if (forceBottom) {
|
|
||||||
container.scrollTop = container.scrollHeight
|
|
||||||
lastKnownScrollTop = container.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
} else {
|
|
||||||
container.scrollTop = lastKnownScrollTop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistScrollSnapshot = (element?: HTMLElement | null) => {
|
|
||||||
if (!element) return
|
|
||||||
lastKnownScrollTop = element.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
function markUserScrollIntent() {
|
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
||||||
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUserScrollIntent() {
|
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
||||||
return now <= userScrollIntentUntil
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachScrollIntentListeners(element: HTMLDivElement) {
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
const handlePointerIntent = () => markUserScrollIntent()
|
|
||||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
|
||||||
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
|
|
||||||
markUserScrollIntent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
|
||||||
element.addEventListener("pointerdown", handlePointerIntent)
|
|
||||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
|
||||||
element.addEventListener("keydown", handleKeyIntent)
|
|
||||||
detachScrollIntentListeners = () => {
|
|
||||||
element.removeEventListener("wheel", handlePointerIntent)
|
|
||||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
|
||||||
element.removeEventListener("touchstart", handlePointerIntent)
|
|
||||||
element.removeEventListener("keydown", handleKeyIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAnchorScroll(immediate = false) {
|
|
||||||
if (!autoScroll()) return
|
|
||||||
const sentinel = bottomSentinel()
|
|
||||||
const container = scrollContainerRef
|
|
||||||
if (!sentinel || !container) return
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
|
||||||
const sentinelRect = sentinel.getBoundingClientRect()
|
|
||||||
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
|
|
||||||
if (Math.abs(delta) > 1) {
|
|
||||||
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
|
||||||
}
|
|
||||||
lastKnownScrollTop = container.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScroll() {
|
|
||||||
const container = scrollContainer()
|
|
||||||
if (!container) return
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
}
|
|
||||||
const isUserScroll = hasUserScrollIntent()
|
|
||||||
pendingScrollFrame = requestAnimationFrame(() => {
|
|
||||||
pendingScrollFrame = null
|
|
||||||
const atBottom = bottomSentinelVisible()
|
|
||||||
if (isUserScroll) {
|
|
||||||
if (atBottom) {
|
|
||||||
if (!autoScroll()) setAutoScroll(true)
|
|
||||||
} else if (autoScroll()) {
|
|
||||||
setAutoScroll(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
||||||
handleScroll()
|
|
||||||
persistScrollSnapshot(event.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScrollRendered = () => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
restoreScrollPosition(autoScroll())
|
|
||||||
scheduleAnchorScroll(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
|
||||||
const next = element || undefined
|
|
||||||
if (next === scrollContainerRef) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scrollContainerRef = next
|
|
||||||
setScrollContainer(scrollContainerRef)
|
|
||||||
if (scrollContainerRef) {
|
|
||||||
// Refresh our snapshot on mount (e.g. when remounting after collapse)
|
|
||||||
lastKnownScrollTop = props.scrollTopSnapshot()
|
|
||||||
restoreScrollPosition(autoScroll())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollHelpers: ToolScrollHelpers = {
|
const scrollHelpers: ToolScrollHelpers = {
|
||||||
registerContainer: (element, options) => {
|
registerContainer: (element, options) => {
|
||||||
if (options?.disableTracking) return
|
followScroll.registerContainer(element, options)
|
||||||
initializeScrollContainer(element)
|
|
||||||
},
|
|
||||||
handleScroll: handleScrollEvent,
|
|
||||||
renderSentinel: (options) => {
|
|
||||||
if (options?.disableTracking) return null
|
|
||||||
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
|
|
||||||
},
|
},
|
||||||
|
handleScroll: followScroll.handleScroll,
|
||||||
|
renderSentinel: followScroll.renderSentinel,
|
||||||
|
restoreAfterRender: followScroll.restoreAfterRender,
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
const handleScrollRendered = () => {
|
||||||
const container = scrollContainer()
|
scrollHelpers.restoreAfterRender()
|
||||||
if (!container) return
|
}
|
||||||
attachScrollIntentListeners(container)
|
|
||||||
onCleanup(() => {
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const container = scrollContainer()
|
|
||||||
const sentinel = bottomSentinel()
|
|
||||||
if (!container || !sentinel) return
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.target === sentinel) {
|
|
||||||
setBottomSentinelVisible(entry.isIntersecting)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
|
||||||
)
|
|
||||||
observer.observe(sentinel)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
|
|||||||
partVersion={options.partVersion}
|
partVersion={options.partVersion}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={options.sessionId}
|
sessionId={options.sessionId}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
forceCollapsed={options.forceCollapsed}
|
forceCollapsed={options.forceCollapsed}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollHelpers,
|
scrollHelpers,
|
||||||
|
onContentRendered: props.onContentRendered,
|
||||||
}
|
}
|
||||||
|
|
||||||
let previousPartVersion: number | undefined
|
let previousPartVersion: number | undefined
|
||||||
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousPartVersion = version
|
previousPartVersion = version
|
||||||
scheduleAnchorScroll(true)
|
scrollHelpers.restoreAfterRender()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (autoScroll()) {
|
if (followScroll.autoScroll()) {
|
||||||
scheduleAnchorScroll(true)
|
scrollHelpers.restoreAfterRender({ forceBottom: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
pendingScrollFrame = null
|
|
||||||
}
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-details">
|
<div class="tool-call-details">
|
||||||
<Show
|
<Show
|
||||||
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return !current
|
return !current
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const statusIcon = () => {
|
|
||||||
const status = toolState()?.status || ""
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return <Hourglass class="w-4 h-4" />
|
|
||||||
case "running":
|
|
||||||
return <Loader2 class="w-4 h-4 animate-spin" />
|
|
||||||
case "completed":
|
|
||||||
return <Check class="w-4 h-4" />
|
|
||||||
case "error":
|
|
||||||
return <XCircle class="w-4 h-4" />
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusClass = () => {
|
const statusClass = () => {
|
||||||
const status = toolState()?.status || "pending"
|
const status = toolState()?.status || "pending"
|
||||||
return `tool-call-status-${status}`
|
return `tool-call-status-${status}`
|
||||||
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<span class="tool-call-header-status" aria-hidden="true">
|
<ToolStatusIndicator status={status} />
|
||||||
{statusIcon()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
import { escapeHtml } from "../../lib/text-render-utils"
|
import { escapeHtml } from "../../lib/text-render-utils"
|
||||||
@@ -11,6 +11,97 @@ type CacheHandle = {
|
|||||||
set(value: unknown): void
|
set(value: unknown): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StableAnsiStreamUpdater {
|
||||||
|
update: (element: HTMLElement, content: string) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
|
||||||
|
const renderer = createAnsiStreamRenderer()
|
||||||
|
let previousContent = ""
|
||||||
|
let ansiActive = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(element: HTMLElement, content: string) {
|
||||||
|
const resetStreaming = !previousContent || !content.startsWith(previousContent)
|
||||||
|
|
||||||
|
if (resetStreaming) {
|
||||||
|
ansiActive = hasAnsi(content)
|
||||||
|
renderer.reset()
|
||||||
|
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
|
||||||
|
previousContent = content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = content.slice(previousContent.length)
|
||||||
|
if (delta.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ansiActive && hasAnsi(delta)) {
|
||||||
|
ansiActive = true
|
||||||
|
renderer.reset()
|
||||||
|
element.innerHTML = renderer.render(content)
|
||||||
|
previousContent = content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ansiActive) {
|
||||||
|
const htmlChunk = renderer.render(delta)
|
||||||
|
if (htmlChunk.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", htmlChunk)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const escapedDelta = escapeHtml(delta)
|
||||||
|
if (escapedDelta.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", escapedDelta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousContent = content
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
previousContent = ""
|
||||||
|
ansiActive = false
|
||||||
|
renderer.reset()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamingAnsiContent(props: {
|
||||||
|
html: string
|
||||||
|
htmlChunk?: string
|
||||||
|
updateMode: "replace" | "append" | "noop"
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = preRef
|
||||||
|
if (!element) return
|
||||||
|
if (props.updateMode === "noop") return
|
||||||
|
if (props.updateMode === "append") {
|
||||||
|
if (element.innerHTML.length === 0) {
|
||||||
|
element.innerHTML = props.html
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunk = props.htmlChunk ?? ""
|
||||||
|
if (chunk.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", chunk)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (element.innerHTML !== props.html) {
|
||||||
|
element.innerHTML = props.html
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
preRef = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||||
|
}
|
||||||
|
|
||||||
export function createAnsiContentRenderer(params: {
|
export function createAnsiContentRenderer(params: {
|
||||||
ansiRunningCache: CacheHandle
|
ansiRunningCache: CacheHandle
|
||||||
ansiFinalCache: CacheHandle
|
ansiFinalCache: CacheHandle
|
||||||
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const isRunningVariant = options.variant === "running"
|
const isRunningVariant = options.variant === "running"
|
||||||
const disableScrollTracking = !isRunningVariant
|
const disableScrollTracking = !isRunningVariant
|
||||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
|
let updateMode: "replace" | "append" | "noop" = "replace"
|
||||||
|
let htmlChunk = ""
|
||||||
|
|
||||||
let nextCache: AnsiRenderCache
|
let nextCache: AnsiRenderCache
|
||||||
|
|
||||||
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||||
|
|
||||||
if (resetStreaming) {
|
if (resetStreaming) {
|
||||||
|
updateMode = "replace"
|
||||||
const detectedAnsi = hasAnsi(content)
|
const detectedAnsi = hasAnsi(content)
|
||||||
if (detectedAnsi) {
|
if (detectedAnsi) {
|
||||||
runningAnsiRenderer.reset()
|
runningAnsiRenderer.reset()
|
||||||
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
|
|||||||
} else {
|
} else {
|
||||||
const delta = content.slice(cached.text.length)
|
const delta = content.slice(cached.text.length)
|
||||||
if (delta.length === 0) {
|
if (delta.length === 0) {
|
||||||
|
updateMode = "noop"
|
||||||
nextCache = { ...cached, mode }
|
nextCache = { ...cached, mode }
|
||||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||||
|
updateMode = "replace"
|
||||||
runningAnsiRenderer.reset()
|
runningAnsiRenderer.reset()
|
||||||
const html = runningAnsiRenderer.render(content)
|
const html = runningAnsiRenderer.render(content)
|
||||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||||
} else if (cached.hasAnsi) {
|
} else if (cached.hasAnsi) {
|
||||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
const appendedHtml = runningAnsiRenderer.render(delta)
|
||||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
updateMode = "append"
|
||||||
|
htmlChunk = appendedHtml
|
||||||
|
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
|
||||||
} else {
|
} else {
|
||||||
|
updateMode = "append"
|
||||||
|
htmlChunk = escapeHtml(delta)
|
||||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
||||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
|
<StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -129,9 +129,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!disableScrollTracking) {
|
params.handleScrollRendered()
|
||||||
params.handleScrollRendered()
|
|
||||||
}
|
|
||||||
params.onContentRendered?.()
|
params.onContentRendered?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,107 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { ToolRenderer, ToolScrollHelpers } from "../types"
|
||||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||||
import { tGlobal } from "../../../lib/i18n"
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
import { createStableAnsiStreamUpdater } from "../ansi-render"
|
||||||
|
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
|
||||||
|
|
||||||
|
function RunningBashOutput(props: {
|
||||||
|
content: Accessor<string>
|
||||||
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
|
const updater = createStableAnsiStreamUpdater()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = preRef
|
||||||
|
if (!element) return
|
||||||
|
updater.update(element, props.content())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
preRef = undefined
|
||||||
|
updater.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="message-text tool-call-markdown"
|
||||||
|
ref={props.scrollHelpers?.registerContainer}
|
||||||
|
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||||
|
>
|
||||||
|
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||||
|
{props.scrollHelpers?.renderSentinel?.()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BashToolBody(props: {
|
||||||
|
toolState: Accessor<ToolState | undefined>
|
||||||
|
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
|
||||||
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
}) {
|
||||||
|
const state = createMemo(() => props.toolState())
|
||||||
|
|
||||||
|
const joinedContent = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
if (!current || current.status === "pending") return ""
|
||||||
|
|
||||||
|
const { input, metadata } = readToolStatePayload(current)
|
||||||
|
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||||
|
const outputResult = formatUnknown(
|
||||||
|
isToolStateCompleted(current)
|
||||||
|
? current.output
|
||||||
|
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
|
||||||
|
? metadata.output
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
return [command, outputResult?.text].filter(Boolean).join("\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalMarkdown = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
const content = joinedContent()
|
||||||
|
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (hasAnsi(content)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ensureMarkdownContent(content, "bash", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalAnsiHtml = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
const content = joinedContent()
|
||||||
|
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!hasAnsi(content)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ansiToHtml(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={state() && joinedContent().length > 0}>
|
||||||
|
<Show
|
||||||
|
when={state()?.status === "running"}
|
||||||
|
fallback={
|
||||||
|
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
|
||||||
|
{(html) => (
|
||||||
|
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
|
||||||
|
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const bashRenderer: ToolRenderer = {
|
export const bashRenderer: ToolRenderer = {
|
||||||
tools: ["bash"],
|
tools: ["bash"],
|
||||||
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
|
|||||||
const timeoutLabel = `${timeout}ms`
|
const timeoutLabel = `${timeout}ms`
|
||||||
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||||
},
|
},
|
||||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
|
||||||
const state = toolState()
|
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
|
||||||
if (!state || state.status === "pending") return null
|
|
||||||
|
|
||||||
const { input, metadata } = readToolStatePayload(state)
|
|
||||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
|
||||||
const outputResult = formatUnknown(
|
|
||||||
isToolStateCompleted(state)
|
|
||||||
? state.output
|
|
||||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
|
||||||
? metadata.output
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
const parts = [command, outputResult?.text].filter(Boolean)
|
|
||||||
if (parts.length === 0) return null
|
|
||||||
|
|
||||||
const joined = parts.join("\n")
|
|
||||||
if (state.status === "running") {
|
|
||||||
return renderAnsi({ content: joined, variant: "running" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
|
|
||||||
if (ansiBody) {
|
|
||||||
return ansiBody
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = ensureMarkdownContent(joined, "bash", true)
|
|
||||||
if (!content) return null
|
|
||||||
|
|
||||||
return renderMarkdown({ content })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
return describeTaskTitle(input)
|
return describeTaskTitle(input)
|
||||||
},
|
},
|
||||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
||||||
|
|
||||||
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const childCount = childToolKeys().length
|
||||||
|
const legacyCount = legacyItems().length
|
||||||
|
if (childCount === 0 && legacyCount === 0) return
|
||||||
|
scrollHelpers?.restoreAfterRender()
|
||||||
|
onContentRendered?.()
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-task-sections">
|
<div class="tool-call-task-sections">
|
||||||
<Show when={promptContent()}>
|
<Show when={promptContent()}>
|
||||||
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="tool-call-task-summary">
|
<div class="tool-call-task-summary">
|
||||||
<For each={childToolKeys()}>
|
<Index each={childToolKeys()}>
|
||||||
{(key) => (
|
{(key) => (
|
||||||
<Show when={renderToolCall}>
|
<Show when={renderToolCall}>
|
||||||
{(render) => (
|
{(render) => (
|
||||||
<TaskToolCallRow
|
<TaskToolCallRow
|
||||||
toolKey={key}
|
toolKey={key()}
|
||||||
store={store}
|
store={store}
|
||||||
sessionId={childSessionId()}
|
sessionId={childSessionId()}
|
||||||
renderToolCall={render()}
|
renderToolCall={render()}
|
||||||
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
{scrollHelpers?.renderSentinel?.()}
|
{scrollHelpers?.renderSentinel?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
|
|||||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||||
|
restoreAfterRender(options?: { forceBottom?: boolean }): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRendererContext {
|
export interface ToolRendererContext {
|
||||||
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
|
|||||||
forceCollapsed?: boolean
|
forceCollapsed?: boolean
|
||||||
}) => JSXElement | null
|
}) => JSXElement | null
|
||||||
scrollHelpers?: ToolScrollHelpers
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRenderer {
|
export interface ToolRenderer {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface UnifiedPickerProps {
|
|||||||
mode?: "mention" | "command"
|
mode?: "mention" | "command"
|
||||||
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onSubmitWithoutSelection?: () => void
|
||||||
agents: Agent[]
|
agents: Agent[]
|
||||||
commands?: SDKCommand[]
|
commands?: SDKCommand[]
|
||||||
instanceClient: OpencodeClient | null
|
instanceClient: OpencodeClient | null
|
||||||
@@ -404,6 +405,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
if (selected) {
|
if (selected) {
|
||||||
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||||
props.onSelect(selected, action)
|
props.onSelect(selected, action)
|
||||||
|
} else if (e.key === "Enter" && mode() === "mention") {
|
||||||
|
props.onSubmitWithoutSelection?.()
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
259
packages/ui/src/lib/follow-scroll.tsx
Normal file
259
packages/ui/src/lib/follow-scroll.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||||
|
|
||||||
|
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
|
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
|
||||||
|
interface FollowScrollOptions {
|
||||||
|
getScrollTopSnapshot: Accessor<number>
|
||||||
|
setScrollTopSnapshot: (next: number) => void
|
||||||
|
sentinelMarginPx: number
|
||||||
|
sentinelClassName: string
|
||||||
|
intentWindowMs?: number
|
||||||
|
intentKeys?: ReadonlySet<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FollowScrollHelpers {
|
||||||
|
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
||||||
|
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
||||||
|
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
||||||
|
restoreAfterRender: (options?: { forceBottom?: boolean }) => void
|
||||||
|
autoScroll: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
|
||||||
|
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
|
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||||
|
|
||||||
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
let detachScrollIntentListeners: (() => void) | undefined
|
||||||
|
|
||||||
|
let pendingScrollFrame: number | null = null
|
||||||
|
let pendingAnchorScroll: number | null = null
|
||||||
|
let userScrollIntentUntil = 0
|
||||||
|
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||||
|
let pointerInteractionActive = false
|
||||||
|
let suppressNextScrollHandling = false
|
||||||
|
|
||||||
|
function restoreScrollPosition(forceBottom = false) {
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (!container) return
|
||||||
|
suppressNextScrollHandling = true
|
||||||
|
if (forceBottom) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
lastKnownScrollTop = container.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
} else {
|
||||||
|
container.scrollTop = lastKnownScrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistScrollSnapshot(element?: HTMLElement | null) {
|
||||||
|
if (!element) return
|
||||||
|
lastKnownScrollTop = element.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markUserScrollIntent() {
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserScrollIntent() {
|
||||||
|
if (pointerInteractionActive) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
return now <= userScrollIntentUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
||||||
|
const handlePointerIntent = () => {
|
||||||
|
pointerInteractionActive = true
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
const clearPointerIntent = () => {
|
||||||
|
pointerInteractionActive = false
|
||||||
|
}
|
||||||
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||||
|
if (intentKeys.has(event.key)) {
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("keydown", handleKeyIntent)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("pointerup", clearPointerIntent)
|
||||||
|
window.addEventListener("pointercancel", clearPointerIntent)
|
||||||
|
window.addEventListener("mouseup", clearPointerIntent)
|
||||||
|
window.addEventListener("touchend", clearPointerIntent)
|
||||||
|
window.addEventListener("touchcancel", clearPointerIntent)
|
||||||
|
}
|
||||||
|
detachScrollIntentListeners = () => {
|
||||||
|
element.removeEventListener("wheel", handlePointerIntent)
|
||||||
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.removeEventListener("touchstart", handlePointerIntent)
|
||||||
|
element.removeEventListener("keydown", handleKeyIntent)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.removeEventListener("pointerup", clearPointerIntent)
|
||||||
|
window.removeEventListener("pointercancel", clearPointerIntent)
|
||||||
|
window.removeEventListener("mouseup", clearPointerIntent)
|
||||||
|
window.removeEventListener("touchend", clearPointerIntent)
|
||||||
|
window.removeEventListener("touchcancel", clearPointerIntent)
|
||||||
|
}
|
||||||
|
pointerInteractionActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAnchorScroll(immediate = false) {
|
||||||
|
if (!autoScroll()) return
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (!sentinel || !container) return
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
const sentinelRect = sentinel.getBoundingClientRect()
|
||||||
|
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
|
||||||
|
if (Math.abs(delta) > 1) {
|
||||||
|
suppressNextScrollHandling = true
|
||||||
|
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||||
|
}
|
||||||
|
lastKnownScrollTop = container.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtBottom(container: HTMLDivElement) {
|
||||||
|
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
||||||
|
const container = containerOverride ?? scrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
if (suppressNextScrollHandling) {
|
||||||
|
suppressNextScrollHandling = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isUserScroll = hasUserScrollIntent()
|
||||||
|
const atBottomFromScroll = isAtBottom(container)
|
||||||
|
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
||||||
|
|
||||||
|
if (isUserScroll || !atBottom) {
|
||||||
|
if (atBottom) {
|
||||||
|
if (!autoScroll()) setAutoScroll(true)
|
||||||
|
} else if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
|
updateFollowModeFromScroll(event.currentTarget)
|
||||||
|
persistScrollSnapshot(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
||||||
|
const next = element || undefined
|
||||||
|
if (next === scrollContainerRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollContainerRef = next
|
||||||
|
setScrollContainer(scrollContainerRef)
|
||||||
|
if (scrollContainerRef) {
|
||||||
|
lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||||
|
restoreScrollPosition(autoScroll())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSentinel = (config?: { disableTracking?: boolean }) => {
|
||||||
|
if (config?.disableTracking) return null
|
||||||
|
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreAfterRender = (config?: { forceBottom?: boolean }) => {
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||||
|
if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition(false)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldFollow = config?.forceBottom ?? autoScroll()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition(shouldFollow)
|
||||||
|
if (shouldFollow) {
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const container = scrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
attachScrollIntentListeners(container)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const container = scrollContainer()
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
if (!container || !sentinel) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.target === sentinel) {
|
||||||
|
setBottomSentinelVisible(entry.isIntersecting)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
|
||||||
|
)
|
||||||
|
observer.observe(sentinel)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
pendingScrollFrame = null
|
||||||
|
}
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerContainer,
|
||||||
|
handleScroll,
|
||||||
|
renderSentinel,
|
||||||
|
restoreAfterRender,
|
||||||
|
autoScroll,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
|
|||||||
sessions: {},
|
sessions: {},
|
||||||
sessionOrder: [],
|
sessionOrder: [],
|
||||||
messages: {},
|
messages: {},
|
||||||
|
lastAssistantMessageIds: {},
|
||||||
messageInfoVersion: {},
|
messageInfoVersion: {},
|
||||||
pendingParts: {},
|
pendingParts: {},
|
||||||
sessionRevisions: {},
|
sessionRevisions: {},
|
||||||
@@ -218,6 +219,7 @@ export interface InstanceMessageStore {
|
|||||||
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
||||||
getSessionRevision: (sessionId: string) => number
|
getSessionRevision: (sessionId: string) => number
|
||||||
getSessionMessageIds: (sessionId: string) => string[]
|
getSessionMessageIds: (sessionId: string) => string[]
|
||||||
|
getLastAssistantMessageId: (sessionId: string) => string | undefined
|
||||||
// Index of the most recent message in the session that contains a compaction part.
|
// Index of the most recent message in the session that contains a compaction part.
|
||||||
// Returns -1 if there has been no compaction.
|
// Returns -1 if there has been no compaction.
|
||||||
getLastCompactionMessageIndex: (sessionId: string) => number
|
getLastCompactionMessageIndex: (sessionId: string) => number
|
||||||
@@ -234,6 +236,21 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
|
|
||||||
const messageInfoCache = new Map<string, MessageInfo>()
|
const messageInfoCache = new Map<string, MessageInfo>()
|
||||||
|
|
||||||
|
function findLastAssistantMessageId(messageIds: readonly string[]): string | undefined {
|
||||||
|
for (let index = messageIds.length - 1; index >= 0; index -= 1) {
|
||||||
|
const messageId = messageIds[index]
|
||||||
|
if (state.messages[messageId]?.role === "assistant") {
|
||||||
|
return messageId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeLastAssistantMessageId(sessionId: string, messageIds?: readonly string[]) {
|
||||||
|
if (!sessionId) return
|
||||||
|
setState("lastAssistantMessageIds", sessionId, findLastAssistantMessageId(messageIds ?? state.sessions[sessionId]?.messageIds ?? []))
|
||||||
|
}
|
||||||
|
|
||||||
function getLastCompactionMessageIndex(sessionId: string): number {
|
function getLastCompactionMessageIndex(sessionId: string): number {
|
||||||
if (!sessionId) return -1
|
if (!sessionId) return -1
|
||||||
const ids = state.sessions[sessionId]?.messageIds ?? []
|
const ids = state.sessions[sessionId]?.messageIds ?? []
|
||||||
@@ -306,6 +323,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return state.sessionRevisions[sessionId] ?? 0
|
return state.sessionRevisions[sessionId] ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLastAssistantMessageIdValue(sessionId: string) {
|
||||||
|
return state.lastAssistantMessageIds[sessionId]
|
||||||
|
}
|
||||||
|
|
||||||
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
|
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
|
||||||
setState("usage", sessionId, (current) => {
|
setState("usage", sessionId, (current) => {
|
||||||
const draft = current
|
const draft = current
|
||||||
@@ -375,6 +396,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
|
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
|
||||||
|
recomputeLastAssistantMessageId(input.id, nextMessageIds)
|
||||||
bumpSessionRevision(input.id)
|
bumpSessionRevision(input.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,6 +467,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
messageIds: incomingIds,
|
messageIds: incomingIds,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}))
|
}))
|
||||||
|
recomputeLastAssistantMessageId(sessionId, incomingIds)
|
||||||
|
|
||||||
Object.values(normalizedRecords).forEach((record) => {
|
Object.values(normalizedRecords).forEach((record) => {
|
||||||
maybeUpdateLatestTodoFromRecord(record)
|
maybeUpdateLatestTodoFromRecord(record)
|
||||||
@@ -516,6 +539,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
|
|
||||||
insertMessageIntoSession(input.sessionId, input.id)
|
insertMessageIntoSession(input.sessionId, input.id)
|
||||||
flushPendingParts(input.id)
|
flushPendingParts(input.id)
|
||||||
|
recomputeLastAssistantMessageId(input.sessionId)
|
||||||
bumpSessionRevision(input.sessionId)
|
bumpSessionRevision(input.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,6 +754,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
if (state.latestTodos[sessionId]?.messageId === messageId) {
|
if (state.latestTodos[sessionId]?.messageId === messageId) {
|
||||||
clearLatestTodoSnapshot(sessionId)
|
clearLatestTodoSnapshot(sessionId)
|
||||||
}
|
}
|
||||||
|
recomputeLastAssistantMessageId(sessionId)
|
||||||
bumpSessionRevision(sessionId)
|
bumpSessionRevision(sessionId)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -816,7 +841,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
affectedSessions.add(session.id)
|
affectedSessions.add(session.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId))
|
affectedSessions.forEach((sessionId) => {
|
||||||
|
recomputeLastAssistantMessageId(sessionId)
|
||||||
|
bumpSessionRevision(sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
const infoEntry = messageInfoCache.get(options.oldId)
|
const infoEntry = messageInfoCache.get(options.oldId)
|
||||||
if (infoEntry) {
|
if (infoEntry) {
|
||||||
@@ -1037,6 +1065,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recomputeLastAssistantMessageId(sessionId, keptIds)
|
||||||
bumpSessionRevision(sessionId)
|
bumpSessionRevision(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1157,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setState("lastAssistantMessageIds", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[sessionId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setState("scrollState", (prev) => {
|
setState("scrollState", (prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
const prefix = `${sessionId}:`
|
const prefix = `${sessionId}:`
|
||||||
@@ -1190,16 +1225,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
|
|
||||||
setSessionRevert,
|
setSessionRevert,
|
||||||
getSessionRevert,
|
getSessionRevert,
|
||||||
rebuildUsage,
|
rebuildUsage,
|
||||||
getSessionUsage,
|
getSessionUsage,
|
||||||
setScrollSnapshot,
|
setScrollSnapshot,
|
||||||
getScrollSnapshot,
|
getScrollSnapshot,
|
||||||
getSessionRevision: getSessionRevisionValue,
|
getSessionRevision: getSessionRevisionValue,
|
||||||
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||||
getLastCompactionMessageIndex,
|
getLastAssistantMessageId: getLastAssistantMessageIdValue,
|
||||||
getMessage: (messageId: string) => state.messages[messageId],
|
getLastCompactionMessageIndex,
|
||||||
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
getMessage: (messageId: string) => state.messages[messageId],
|
||||||
clearSession,
|
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
||||||
clearInstance,
|
clearSession,
|
||||||
}
|
clearInstance,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export interface InstanceMessageState {
|
|||||||
sessions: Record<string, SessionRecord>
|
sessions: Record<string, SessionRecord>
|
||||||
sessionOrder: string[]
|
sessionOrder: string[]
|
||||||
messages: Record<string, MessageRecord>
|
messages: Record<string, MessageRecord>
|
||||||
|
lastAssistantMessageIds: Record<string, string | undefined>
|
||||||
messageInfoVersion: Record<string, number>
|
messageInfoVersion: Record<string, number>
|
||||||
pendingParts: Record<string, PendingPartEntry[]>
|
pendingParts: Record<string, PendingPartEntry[]>
|
||||||
sessionRevisions: Record<string, number>
|
sessionRevisions: Record<string, number>
|
||||||
|
|||||||
Reference in New Issue
Block a user