mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-26 02:24:43 +00:00
fix(app): timeline jank
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, type JSX } from "solid-js"
|
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -711,28 +711,34 @@ export function MessageTimeline(props: {
|
|||||||
<div class="w-full px-4 md:px-5 pb-2">
|
<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="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
<For each={comments()}>
|
<Index each={comments()}>
|
||||||
{(comment) => (
|
{(commentAccessor: () => MessageComment) => {
|
||||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
const comment = createMemo(() => commentAccessor())
|
||||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
return (
|
||||||
<FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
|
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||||
<span class="truncate">{getFilename(comment.path)}</span>
|
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||||
<Show when={comment.selection}>
|
<FileIcon
|
||||||
{(selection) => (
|
node={{ path: comment().path, type: "file" }}
|
||||||
<span class="shrink-0 text-text-weak">
|
class="size-3.5 shrink-0"
|
||||||
{selection().startLine === selection().endLine
|
/>
|
||||||
? `:${selection().startLine}`
|
<span class="truncate">{getFilename(comment().path)}</span>
|
||||||
: `:${selection().startLine}-${selection().endLine}`}
|
<Show when={comment().selection}>
|
||||||
</span>
|
{(selection) => (
|
||||||
)}
|
<span class="shrink-0 text-text-weak">
|
||||||
</Show>
|
{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>
|
</div>
|
||||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
)
|
||||||
{comment.comment}
|
}}
|
||||||
</div>
|
</Index>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -762,10 +762,12 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
|||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<div data-component="context-tool-group-list">
|
<div data-component="context-tool-group-list">
|
||||||
<For each={props.parts}>
|
<Index each={props.parts}>
|
||||||
{(part) => {
|
{(partAccessor) => {
|
||||||
const trigger = contextToolTrigger(part, i18n)
|
const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
|
||||||
const running = part.state.status === "pending" || part.state.status === "running"
|
const running = createMemo(
|
||||||
|
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div data-slot="context-tool-group-item">
|
<div data-slot="context-tool-group-item">
|
||||||
<div data-component="tool-trigger">
|
<div data-component="tool-trigger">
|
||||||
@@ -774,13 +776,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
|||||||
<div data-slot="basic-tool-tool-info-structured">
|
<div data-slot="basic-tool-tool-info-structured">
|
||||||
<div data-slot="basic-tool-tool-info-main">
|
<div data-slot="basic-tool-tool-info-main">
|
||||||
<span data-slot="basic-tool-tool-title">
|
<span data-slot="basic-tool-tool-title">
|
||||||
<TextShimmer text={trigger.title} active={running} />
|
<TextShimmer text={trigger().title} active={running()} />
|
||||||
</span>
|
</span>
|
||||||
<Show when={!running && trigger.subtitle}>
|
<Show when={!running() && trigger().subtitle}>
|
||||||
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span>
|
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!running && trigger.args?.length}>
|
<Show when={!running() && trigger().args?.length}>
|
||||||
<For each={trigger.args}>
|
<For each={trigger().args}>
|
||||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -792,7 +794,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
@@ -1096,30 +1098,30 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
|
|||||||
|
|
||||||
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const part = props.part as ToolPart
|
const part = () => props.part as ToolPart
|
||||||
if (part.tool === "todowrite" || part.tool === "todoread") return null
|
if (part().tool === "todowrite" || part().tool === "todoread") return null
|
||||||
|
|
||||||
const hideQuestion = createMemo(
|
const hideQuestion = createMemo(
|
||||||
() => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"),
|
() => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const emptyInput: Record<string, any> = {}
|
const emptyInput: Record<string, any> = {}
|
||||||
const emptyMetadata: Record<string, any> = {}
|
const emptyMetadata: Record<string, any> = {}
|
||||||
|
|
||||||
const input = () => part.state?.input ?? emptyInput
|
const input = () => part().state?.input ?? emptyInput
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const partMetadata = () => part.state?.metadata ?? emptyMetadata
|
const partMetadata = () => part().state?.metadata ?? emptyMetadata
|
||||||
|
|
||||||
const render = ToolRegistry.render(part.tool) ?? GenericTool
|
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={!hideQuestion()}>
|
<Show when={!hideQuestion()}>
|
||||||
<div data-component="tool-part-wrapper">
|
<div data-component="tool-part-wrapper">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={part.state.status === "error" && part.state.error}>
|
<Match when={part().state.status === "error" && (part().state as any).error}>
|
||||||
{(error) => {
|
{(error) => {
|
||||||
const cleaned = error().replace("Error: ", "")
|
const cleaned = error().replace("Error: ", "")
|
||||||
if (part.tool === "question" && cleaned.includes("dismissed this question")) {
|
if (part().tool === "question" && cleaned.includes("dismissed this question")) {
|
||||||
return (
|
return (
|
||||||
<div style="width: 100%; display: flex; justify-content: flex-end;">
|
<div style="width: 100%; display: flex; justify-content: flex-end;">
|
||||||
<span class="text-13-regular text-text-weak cursor-default">
|
<span class="text-13-regular text-text-weak cursor-default">
|
||||||
@@ -1151,13 +1153,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={render}
|
component={render()}
|
||||||
input={input()}
|
input={input()}
|
||||||
tool={part.tool}
|
tool={part().tool}
|
||||||
metadata={partMetadata()}
|
metadata={partMetadata()}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
output={part.state.output}
|
output={part().state.output}
|
||||||
status={part.state.status}
|
status={part().state.status}
|
||||||
hideDetails={props.hideDetails}
|
hideDetails={props.hideDetails}
|
||||||
defaultOpen={props.defaultOpen}
|
defaultOpen={props.defaultOpen}
|
||||||
/>
|
/>
|
||||||
@@ -1186,7 +1188,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
|
|||||||
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const part = props.part as TextPart
|
const part = () => props.part as TextPart
|
||||||
const interrupted = createMemo(
|
const interrupted = createMemo(
|
||||||
() =>
|
() =>
|
||||||
props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
|
props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
|
||||||
@@ -1229,18 +1231,18 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
return items.filter((x) => !!x).join(" \u00B7 ")
|
return items.filter((x) => !!x).join(" \u00B7 ")
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayText = () => (part.text ?? "").trim()
|
const displayText = () => (part().text ?? "").trim()
|
||||||
const throttledText = createThrottledValue(displayText)
|
const throttledText = createThrottledValue(displayText)
|
||||||
const isLastTextPart = createMemo(() => {
|
const isLastTextPart = createMemo(() => {
|
||||||
const last = (data.store.part?.[props.message.id] ?? [])
|
const last = (data.store.part?.[props.message.id] ?? [])
|
||||||
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
|
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
|
||||||
.at(-1)
|
.at(-1)
|
||||||
return last?.id === part.id
|
return last?.id === part().id
|
||||||
})
|
})
|
||||||
const showCopy = createMemo(() => {
|
const showCopy = createMemo(() => {
|
||||||
if (props.message.role !== "assistant") return isLastTextPart()
|
if (props.message.role !== "assistant") return isLastTextPart()
|
||||||
if (props.showAssistantCopyPartID === null) return false
|
if (props.showAssistantCopyPartID === null) return false
|
||||||
if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part.id
|
if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
|
||||||
return isLastTextPart()
|
return isLastTextPart()
|
||||||
})
|
})
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
@@ -1257,7 +1259,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
<Show when={throttledText()}>
|
<Show when={throttledText()}>
|
||||||
<div data-component="text-part">
|
<div data-component="text-part">
|
||||||
<div data-slot="text-part-body">
|
<div data-slot="text-part-body">
|
||||||
<Markdown text={throttledText()} cacheKey={part.id} />
|
<Markdown text={throttledText()} cacheKey={part().id} />
|
||||||
</div>
|
</div>
|
||||||
<Show when={showCopy()}>
|
<Show when={showCopy()}>
|
||||||
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
|
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
|
||||||
@@ -1288,14 +1290,14 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
||||||
const part = props.part as ReasoningPart
|
const part = () => props.part as ReasoningPart
|
||||||
const text = () => part.text.trim()
|
const text = () => part().text.trim()
|
||||||
const throttledText = createThrottledValue(text)
|
const throttledText = createThrottledValue(text)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={throttledText()}>
|
<Show when={throttledText()}>
|
||||||
<div data-component="reasoning-part">
|
<div data-component="reasoning-part">
|
||||||
<Markdown text={throttledText()} cacheKey={part.id} />
|
<Markdown text={throttledText()} cacheKey={part().id} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
autoTimer = setTimeout(() => {
|
autoTimer = setTimeout(() => {
|
||||||
auto = undefined
|
auto = undefined
|
||||||
autoTimer = undefined
|
autoTimer = undefined
|
||||||
}, 250)
|
}, 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuto = (el: HTMLElement) => {
|
const isAuto = (el: HTMLElement) => {
|
||||||
const a = auto
|
const a = auto
|
||||||
if (!a) return false
|
if (!a) return false
|
||||||
|
|
||||||
if (Date.now() - a.time > 250) {
|
if (Date.now() - a.time > 1500) {
|
||||||
auto = undefined
|
auto = undefined
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user