fix(ui): fix useRowWipe stuck blur and useCollapsible race conditions
- Remove anim.stop() from useRowWipe cleanup — stopping mid-animation leaves WAAPI fill-forward that overrides cleared inline styles. Let animations run to completion; cancelAnimationFrame prevents starts. - Add generation counter to useCollapsible to guard against stale microtask and promise callbacks on rapid open/close toggling. - Use .then(ok, err) instead of .catch().then() to prevent callbacks firing after animation cancellation. - Remove redundant fade constant in ShellExpanded. - Clean up unused imports in context-tool-results.tsx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>pull/16405/head
parent
0ac8f06521
commit
b47ab35ddf
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createMemo, createSignal, For, onMount } from "solid-js"
|
||||
import { createMemo, createSignal, For, onMount } from "solid-js"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
|
@ -7,15 +7,9 @@ import { ToolCall } from "./basic-tool"
|
|||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
import { AnimatedCountList } from "./tool-count-summary"
|
||||
import { RollingResults } from "./rolling-results"
|
||||
import {
|
||||
animate,
|
||||
clearFadeStyles,
|
||||
clearMaskStyles,
|
||||
GROW_SPRING,
|
||||
WIPE_MASK,
|
||||
} from "./motion"
|
||||
import { GROW_SPRING } from "./motion"
|
||||
import { useSpring } from "./motion-spring"
|
||||
import { busy, updateScrollMask, useCollapsible } from "./tool-utils"
|
||||
import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
|
||||
|
||||
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
|
||||
const state = part.state
|
||||
|
|
@ -180,35 +174,11 @@ export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: b
|
|||
<span data-slot="context-tool-rolling-action">{label().action}</span>
|
||||
{(() => {
|
||||
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
|
||||
createEffect(() => {
|
||||
const el = detailRef()
|
||||
const d = label().detail
|
||||
if (!el || !d) return
|
||||
if (wiped.has(k)) return
|
||||
wiped.add(k)
|
||||
if (reduce()) return
|
||||
el.style.maskImage = WIPE_MASK
|
||||
el.style.webkitMaskImage = WIPE_MASK
|
||||
el.style.maskSize = "240% 100%"
|
||||
el.style.webkitMaskSize = "240% 100%"
|
||||
el.style.maskRepeat = "no-repeat"
|
||||
el.style.webkitMaskRepeat = "no-repeat"
|
||||
el.style.maskPosition = "100% 0%"
|
||||
el.style.webkitMaskPosition = "100% 0%"
|
||||
animate(
|
||||
el,
|
||||
{
|
||||
opacity: [0, 1],
|
||||
filter: ["blur(2px)", "blur(0px)"],
|
||||
transform: ["translateX(-0.06em)", "translateX(0)"],
|
||||
maskPosition: "0% 0%",
|
||||
},
|
||||
GROW_SPRING,
|
||||
).finished.then(() => {
|
||||
if (!el) return
|
||||
clearFadeStyles(el)
|
||||
clearMaskStyles(el)
|
||||
})
|
||||
useRowWipe({
|
||||
id: () => k,
|
||||
text: () => label().detail,
|
||||
ref: detailRef,
|
||||
seen: wiped,
|
||||
})
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
[data-component="assistant-message"] {
|
||||
content-visibility: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="assistant-parts"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-component="assistant-part-item"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="user-message"] {
|
||||
|
|
@ -200,7 +210,7 @@
|
|||
[data-component="text-part"] {
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
padding-bottom: 8px;
|
||||
padding-block: 4px;
|
||||
position: relative;
|
||||
|
||||
[data-slot="text-part-body"] {
|
||||
|
|
@ -218,6 +228,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
[data-component="compaction-part"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
|
@ -795,7 +809,6 @@
|
|||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.shell-rolling-copy {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
|
|
@ -836,6 +849,122 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-preview"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="shell-expanded-output"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-expanded-shell"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-body"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-top"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 9px 44px 9px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-command"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-prompt"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-input"] {
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-actions"] {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.shell-expanded-copy {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-weaker);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-divider"] {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-pre"] {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-command"],
|
||||
[data-component="shell-rolling-row"] {
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,4 @@
|
|||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
Show,
|
||||
Switch,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
|
@ -48,9 +37,7 @@ import { IconButton } from "./icon-button"
|
|||
import { TextShimmer } from "./text-shimmer"
|
||||
import { list } from "./text-utils"
|
||||
import { GrowBox } from "./grow-box"
|
||||
import {
|
||||
COLLAPSIBLE_SPRING,
|
||||
} from "./motion"
|
||||
import { COLLAPSIBLE_SPRING } from "./motion"
|
||||
import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
|
||||
import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
|
||||
import { ShellRollingResults } from "./shell-rolling-results"
|
||||
|
|
@ -453,163 +440,186 @@ export function AssistantParts(props: {
|
|||
const last = createMemo(() => grouped().keys.at(-1))
|
||||
|
||||
return (
|
||||
<For each={grouped().keys}>
|
||||
{(key, idx) => {
|
||||
const item = createMemo(() => grouped().items[key])
|
||||
const ctx = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "context") return
|
||||
return value
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "part") return
|
||||
return value
|
||||
})
|
||||
const tail = createMemo(() => last() === key)
|
||||
const tool = createMemo(() => {
|
||||
const value = part()
|
||||
if (!value) return false
|
||||
return value.part.type === "tool"
|
||||
})
|
||||
const context = createMemo(() => !!part()?.context)
|
||||
const contextSpring = createMemo(() => {
|
||||
const entry = part()
|
||||
if (!entry?.context) return undefined
|
||||
if (!groupState.controlled(entry.groupKey)) return undefined
|
||||
return COLLAPSIBLE_SPRING
|
||||
})
|
||||
const contextOpen = createMemo(() => {
|
||||
const collapse = (
|
||||
afterTool?: boolean,
|
||||
groupTail?: boolean,
|
||||
group?: { part: ToolPart; message: AssistantMessage }[],
|
||||
) =>
|
||||
shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], {
|
||||
afterTool,
|
||||
groupTail,
|
||||
working: props.working,
|
||||
})
|
||||
const value = ctx()
|
||||
if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts))
|
||||
const entry = part()
|
||||
return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts))
|
||||
})
|
||||
const visible = createMemo(() => {
|
||||
if (!context()) return true
|
||||
// The context group header is always visible (it has its own expand arrow).
|
||||
if (ctx()) return true
|
||||
// Individual context parts are rendered inside the header's collapsible content,
|
||||
// so they're always hidden at this level.
|
||||
return false
|
||||
})
|
||||
<div data-component="assistant-parts">
|
||||
<For each={grouped().keys}>
|
||||
{(key) => {
|
||||
const item = createMemo(() => grouped().items[key])
|
||||
const ctx = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "context") return
|
||||
return value
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "part") return
|
||||
return value
|
||||
})
|
||||
const tail = createMemo(() => last() === key)
|
||||
const tool = createMemo(() => {
|
||||
const value = part()
|
||||
if (!value) return false
|
||||
return value.part.type === "tool"
|
||||
})
|
||||
const context = createMemo(() => !!part()?.context)
|
||||
const contextSpring = createMemo(() => {
|
||||
const entry = part()
|
||||
if (!entry?.context) return undefined
|
||||
if (!groupState.controlled(entry.groupKey)) return undefined
|
||||
return COLLAPSIBLE_SPRING
|
||||
})
|
||||
const contextOpen = createMemo(() => {
|
||||
const collapse = (
|
||||
afterTool?: boolean,
|
||||
groupTail?: boolean,
|
||||
group?: { part: ToolPart; message: AssistantMessage }[],
|
||||
) =>
|
||||
shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], {
|
||||
afterTool,
|
||||
groupTail,
|
||||
working: props.working,
|
||||
})
|
||||
const value = ctx()
|
||||
if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts))
|
||||
const entry = part()
|
||||
return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts))
|
||||
})
|
||||
const visible = createMemo(() => {
|
||||
if (!context()) return true
|
||||
if (ctx()) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const turnSummary = createMemo(() => {
|
||||
const value = part()
|
||||
if (!value) return false
|
||||
if (value.part.type !== "text") return false
|
||||
if (!props.showTurnDiffSummary) return false
|
||||
return props.showAssistantCopyPartID === value.part.id
|
||||
})
|
||||
const fade = createMemo(() => {
|
||||
if (ctx()) return true
|
||||
return tool()
|
||||
})
|
||||
const edge = createMemo(() => {
|
||||
const entry = part()
|
||||
if (!entry) return false
|
||||
if (entry.part.type !== "text") return false
|
||||
if (!props.working) return false
|
||||
return tail()
|
||||
})
|
||||
const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary())
|
||||
const ctxPartsCache = new Map<string, ToolPart>()
|
||||
let ctxPartsPrev: ToolPart[] = []
|
||||
const ctxParts = createMemo(() => {
|
||||
const parts = ctx()?.parts ?? []
|
||||
// Guard against transient empty flash during store recomputation
|
||||
if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev
|
||||
const result: ToolPart[] = []
|
||||
for (const item of parts) {
|
||||
const k = item.part.callID || item.part.id
|
||||
const cached = ctxPartsCache.get(k)
|
||||
if (cached) {
|
||||
result.push(cached)
|
||||
} else {
|
||||
ctxPartsCache.set(k, item.part)
|
||||
result.push(item.part)
|
||||
const turnSummary = createMemo(() => {
|
||||
const value = part()
|
||||
if (!value) return false
|
||||
if (value.part.type !== "text") return false
|
||||
if (!props.showTurnDiffSummary) return false
|
||||
return props.showAssistantCopyPartID === value.part.id
|
||||
})
|
||||
const fade = createMemo(() => {
|
||||
if (ctx()) return true
|
||||
return tool()
|
||||
})
|
||||
const edge = createMemo(() => {
|
||||
const entry = part()
|
||||
if (!entry) return false
|
||||
if (entry.part.type !== "text") return false
|
||||
if (!props.working) return false
|
||||
return tail()
|
||||
})
|
||||
const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary())
|
||||
const ctxPartsCache = new Map<string, ToolPart>()
|
||||
let ctxPartsPrev: ToolPart[] = []
|
||||
const ctxParts = createMemo(() => {
|
||||
const parts = ctx()?.parts ?? []
|
||||
if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev
|
||||
const result: ToolPart[] = []
|
||||
for (const item of parts) {
|
||||
const k = item.part.callID || item.part.id
|
||||
const cached = ctxPartsCache.get(k)
|
||||
if (cached) {
|
||||
result.push(cached)
|
||||
} else {
|
||||
ctxPartsCache.set(k, item.part)
|
||||
result.push(item.part)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctxPartsPrev = result
|
||||
return result
|
||||
})
|
||||
const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
|
||||
const ctxPending = ctxPendingRaw
|
||||
const ctxHoldOpen = hold(ctxPendingRaw)
|
||||
const shell = createMemo(() => {
|
||||
const value = part()
|
||||
if (!value) return
|
||||
if (value.part.type !== "tool") return
|
||||
if (value.part.tool !== "bash") return
|
||||
return value.part
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<PartGrow
|
||||
animate={props.animate}
|
||||
gap={idx() === 0 || fade() ? 0 : 8}
|
||||
fade={fade()}
|
||||
edge={edge()}
|
||||
edgeHeight={20}
|
||||
edgeOpacity={0.95}
|
||||
edgeIdle={100}
|
||||
edgeFade={0.6}
|
||||
edgeRise={0.1}
|
||||
grow
|
||||
watch={watch()}
|
||||
animateToggle
|
||||
open={visible()}
|
||||
toggleSpring={contextSpring()}
|
||||
>
|
||||
<Show when={ctx()}>
|
||||
{(entry) => (
|
||||
<ContextToolGroupHeader
|
||||
parts={ctxParts()}
|
||||
pending={ctxPending()}
|
||||
open={contextOpen()}
|
||||
onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!shell() ? part() : undefined}>
|
||||
{(entry) => (
|
||||
<div data-component={entry().context ? "context-tool-step" : undefined}>
|
||||
<Part
|
||||
part={entry().part}
|
||||
message={entry().message}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
showTurnDiffSummary={props.showTurnDiffSummary}
|
||||
turnDiffSummary={props.turnDiffSummary}
|
||||
defaultOpen={partDefaultOpen(entry().part, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
|
||||
hideDetails={entry().context}
|
||||
animate={props.animate}
|
||||
working={props.working}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</PartGrow>
|
||||
<Show when={ctx()}>
|
||||
<ContextToolExpandedList parts={ctxParts()} expanded={!ctxPending() && contextOpen()} />
|
||||
ctxPartsPrev = result
|
||||
return result
|
||||
})
|
||||
const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
|
||||
const ctxPending = ctxPendingRaw
|
||||
const ctxHoldOpen = hold(ctxPendingRaw)
|
||||
const shell = createMemo(() => {
|
||||
const value = part()
|
||||
if (!value) return
|
||||
if (value.part.type !== "tool") return
|
||||
if (value.part.tool !== "bash") return
|
||||
return value.part
|
||||
})
|
||||
const kind = createMemo(() => {
|
||||
if (ctx()) return "context"
|
||||
if (shell()) return "shell"
|
||||
const value = part()
|
||||
if (!value) return "part"
|
||||
return value.part.type
|
||||
})
|
||||
const shown = createMemo(() => {
|
||||
if (ctx()) return true
|
||||
if (shell()) return true
|
||||
const entry = part()
|
||||
if (!entry) return false
|
||||
return !entry.context
|
||||
})
|
||||
const partGrowProps = () => ({
|
||||
animate: props.animate,
|
||||
gap: 0,
|
||||
fade: fade(),
|
||||
edge: edge(),
|
||||
edgeHeight: 20,
|
||||
edgeOpacity: 0.95,
|
||||
edgeIdle: 100,
|
||||
edgeFade: 0.6,
|
||||
edgeRise: 0.1,
|
||||
grow: true,
|
||||
watch: watch(),
|
||||
animateToggle: true,
|
||||
open: visible(),
|
||||
toggleSpring: contextSpring(),
|
||||
})
|
||||
return (
|
||||
<Show when={shown()}>
|
||||
<div data-component="assistant-part-item" data-kind={kind()} data-last={tail() ? "true" : "false"}>
|
||||
<Show when={ctx()}>
|
||||
{(entry) => (
|
||||
<>
|
||||
<PartGrow {...partGrowProps()}>
|
||||
<ContextToolGroupHeader
|
||||
parts={ctxParts()}
|
||||
pending={ctxPending()}
|
||||
open={contextOpen()}
|
||||
onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)}
|
||||
/>
|
||||
</PartGrow>
|
||||
<ContextToolExpandedList parts={ctxParts()} expanded={!ctxPending() && contextOpen()} />
|
||||
<ContextToolRollingResults parts={ctxParts()} pending={ctxHoldOpen()} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={shell()}>{(value) => <ShellRollingResults part={value()} animate={props.animate} />}</Show>
|
||||
<Show when={!shell() ? part() : undefined}>
|
||||
{(entry) => (
|
||||
<Show when={!entry().context}>
|
||||
<PartGrow {...partGrowProps()}>
|
||||
<div>
|
||||
<Part
|
||||
part={entry().part}
|
||||
message={entry().message}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
showTurnDiffSummary={props.showTurnDiffSummary}
|
||||
turnDiffSummary={props.turnDiffSummary}
|
||||
defaultOpen={partDefaultOpen(
|
||||
entry().part,
|
||||
props.shellToolDefaultOpen,
|
||||
props.editToolDefaultOpen,
|
||||
)}
|
||||
hideDetails={false}
|
||||
animate={props.animate}
|
||||
working={props.working}
|
||||
/>
|
||||
</div>
|
||||
</PartGrow>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<ContextToolRollingResults parts={ctxParts()} pending={ctxHoldOpen()} />
|
||||
<Show when={shell()}>{(value) => <ShellRollingResults part={value()} animate={props.animate} />}</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -647,7 +657,6 @@ export function registerPartComponent(type: string, component: PartComponent) {
|
|||
PART_MAPPING[type] = component
|
||||
}
|
||||
|
||||
|
||||
export function UserMessageDisplay(props: {
|
||||
message: UserMessage
|
||||
parts: PartType[]
|
||||
|
|
@ -1638,7 +1647,7 @@ ToolRegistry.register({
|
|||
variant="panel"
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
springContent
|
||||
defer
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
|
|
@ -1709,7 +1718,7 @@ ToolRegistry.register({
|
|||
variant="panel"
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
springContent
|
||||
defer
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type RollingResultsProps<T> = {
|
|||
animate?: boolean
|
||||
class?: string
|
||||
empty?: JSX.Element
|
||||
noFadeOnCollapse?: boolean
|
||||
}
|
||||
|
||||
export function RollingResults<T>(props: RollingResultsProps<T>) {
|
||||
|
|
@ -54,6 +55,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
|
|||
})
|
||||
const open = createMemo(() => props.open !== false)
|
||||
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
|
||||
const noFade = () => props.noFadeOnCollapse === true
|
||||
const overflowing = createMemo(() => count() > rows())
|
||||
const shown = createMemo(() => Math.min(rows(), count()))
|
||||
const step = createMemo(() => rowHeight() + rowGap())
|
||||
|
|
@ -142,22 +144,24 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
|
|||
}
|
||||
// Wait for the current offset animation to settle (if any).
|
||||
const done = shift?.finished ?? Promise.resolve()
|
||||
done.catch(() => {}).then(() => {
|
||||
if (props.scrollable !== true) return
|
||||
done
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (props.scrollable !== true) return
|
||||
|
||||
// Batch the signal update — Solid updates the DOM synchronously:
|
||||
// rendered() returns all items, skipped() returns 0, padding-top removed,
|
||||
// data-scrollable becomes "true".
|
||||
batch(() => setScrollReady(true))
|
||||
// Batch the signal update — Solid updates the DOM synchronously:
|
||||
// rendered() returns all items, skipped() returns 0, padding-top removed,
|
||||
// data-scrollable becomes "true".
|
||||
batch(() => setScrollReady(true))
|
||||
|
||||
// Now the DOM has all items. Safe to switch layout strategy.
|
||||
// CSS handles `transform: none !important` on [data-scrollable="true"].
|
||||
if (windowEl) {
|
||||
windowEl.style.overflowY = "auto"
|
||||
windowEl.scrollTop = windowEl.scrollHeight
|
||||
}
|
||||
updateScrollMask()
|
||||
})
|
||||
// Now the DOM has all items. Safe to switch layout strategy.
|
||||
// CSS handles `transform: none !important` on [data-scrollable="true"].
|
||||
if (windowEl) {
|
||||
windowEl.style.overflowY = "auto"
|
||||
windowEl.scrollTop = windowEl.scrollHeight
|
||||
}
|
||||
updateScrollMask()
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -239,26 +243,28 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
|
|||
resize?.stop()
|
||||
resize = undefined
|
||||
setView(next)
|
||||
view.style.opacity = ""
|
||||
clearEdge()
|
||||
return
|
||||
}
|
||||
const collapsing = next === 0 && prev !== undefined && prev > 0
|
||||
const expanding = prev === 0 && next > 0
|
||||
resize?.stop()
|
||||
view.style.opacity = ""
|
||||
applyEdge()
|
||||
const spring = props.spring ?? GROW_SPRING
|
||||
const anim = collapsing
|
||||
? animate(view, { height: `${next}px`, opacity: 0 }, spring)
|
||||
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
|
||||
: expanding
|
||||
? animate(view, { height: `${next}px`, opacity: [0, 1] }, spring)
|
||||
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring)
|
||||
: animate(view, { height: `${next}px` }, spring)
|
||||
resize = anim
|
||||
anim.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
view.style.opacity = ""
|
||||
if (resize !== anim) return
|
||||
setView(next)
|
||||
if (collapsing || expanding) view!.style.opacity = ""
|
||||
resize = undefined
|
||||
clearEdge()
|
||||
})
|
||||
|
|
@ -299,7 +305,11 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
|
|||
<Show when={list().length === 0 && props.empty !== undefined}>
|
||||
<div data-slot="rolling-results-empty">{props.empty}</div>
|
||||
</Show>
|
||||
<div ref={track} data-slot="rolling-results-track" style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}>
|
||||
<div
|
||||
ref={track}
|
||||
data-slot="rolling-results-track"
|
||||
style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}
|
||||
>
|
||||
<For each={rendered()}>
|
||||
{(item, index) => (
|
||||
<div data-slot="rolling-results-row" data-key={key(item, index())}>
|
||||
|
|
|
|||
|
|
@ -549,15 +549,26 @@ function buildReadEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
|||
const events: TimelineEvent[] = [
|
||||
{ type: "part", part: readPart },
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: readPart.id, patch: { state: { status: "pending", input: { filePath }, raw: JSON.stringify({ filePath }) } } },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: readPart.id,
|
||||
patch: { state: { status: "pending", input: { filePath }, raw: JSON.stringify({ filePath }) } },
|
||||
},
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: readPart.id, patch: toolRunning(readPart, fileName, t) },
|
||||
]
|
||||
return [events, {
|
||||
part: readPart, turn, title: fileName, startTime: t,
|
||||
completeOutput: `// contents of ${fileName}`,
|
||||
completePatch: toolCompleted(readPart, fileName, `// contents of ${fileName}`, t, t + 300),
|
||||
}]
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: readPart,
|
||||
turn,
|
||||
title: fileName,
|
||||
startTime: t,
|
||||
completeOutput: `// contents of ${fileName}`,
|
||||
completePatch: toolCompleted(readPart, fileName, `// contents of ${fileName}`, t, t + 300),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Bash output chunks — each press of `b` appends the next chunk to the running tool
|
||||
|
|
@ -656,13 +667,25 @@ function buildBashStartEvents(turn: TurnState): [TimelineEvent[], RunningTool, n
|
|||
const events: TimelineEvent[] = [
|
||||
{ type: "part", part: shellPart },
|
||||
{ type: "delay", ms: 120 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: shellPart.id, patch: toolRunning(shellPart, input.command, t) },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: shellPart.id,
|
||||
patch: toolRunning(shellPart, input.command, t),
|
||||
},
|
||||
]
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: shellPart,
|
||||
turn,
|
||||
title: input.command,
|
||||
startTime: t,
|
||||
completeOutput: fullOutput,
|
||||
completePatch: toolCompleted(shellPart, input.command, fullOutput, t, t + 2000),
|
||||
},
|
||||
cmdIdx,
|
||||
]
|
||||
return [events, {
|
||||
part: shellPart, turn, title: input.command, startTime: t,
|
||||
completeOutput: fullOutput,
|
||||
completePatch: toolCompleted(shellPart, input.command, fullOutput, t, t + 2000),
|
||||
}, cmdIdx]
|
||||
}
|
||||
|
||||
function buildBashChunkEvents(
|
||||
|
|
@ -681,7 +704,13 @@ function buildBashChunkEvents(
|
|||
messageID: turn.asstMsgID,
|
||||
partID: part.id,
|
||||
patch: {
|
||||
state: { status: "running", input: part.state.input, title: part.state.input?.command, output: newOutput, time: { start: Date.now() } },
|
||||
state: {
|
||||
status: "running",
|
||||
input: part.state.input,
|
||||
title: part.state.input?.command,
|
||||
output: newOutput,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -723,15 +752,26 @@ function buildGrepEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
|||
const events: TimelineEvent[] = [
|
||||
{ type: "part", part: grepPart },
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: grepPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: grepPart.id,
|
||||
patch: { state: { status: "pending", input, raw: JSON.stringify(input) } },
|
||||
},
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: grepPart.id, patch: toolRunning(grepPart, title, t) },
|
||||
]
|
||||
return [events, {
|
||||
part: grepPart, turn, title, startTime: t,
|
||||
completeOutput: "14 matches found",
|
||||
completePatch: toolCompleted(grepPart, title, "14 matches found", t, t + 400),
|
||||
}]
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: grepPart,
|
||||
turn,
|
||||
title,
|
||||
startTime: t,
|
||||
completeOutput: "14 matches found",
|
||||
completePatch: toolCompleted(grepPart, title, "14 matches found", t, t + 400),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const globPatterns = ["**/*.ts", "**/*.tsx", "src/**/*.css", "packages/*/package.json", "**/*.test.ts"]
|
||||
|
|
@ -745,15 +785,26 @@ function buildGlobEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
|||
const events: TimelineEvent[] = [
|
||||
{ type: "part", part: globPart },
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: globPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: globPart.id,
|
||||
patch: { state: { status: "pending", input, raw: JSON.stringify(input) } },
|
||||
},
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: globPart.id, patch: toolRunning(globPart, pattern, t) },
|
||||
]
|
||||
return [events, {
|
||||
part: globPart, turn, title: pattern, startTime: t,
|
||||
completeOutput: "23 files matched",
|
||||
completePatch: toolCompleted(globPart, pattern, "23 files matched", t, t + 200),
|
||||
}]
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: globPart,
|
||||
turn,
|
||||
title: pattern,
|
||||
startTime: t,
|
||||
completeOutput: "23 files matched",
|
||||
completePatch: toolCompleted(globPart, pattern, "23 files matched", t, t + 200),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const listPaths = [
|
||||
|
|
@ -773,15 +824,26 @@ function buildListEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
|||
const events: TimelineEvent[] = [
|
||||
{ type: "part", part: listPart },
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: listPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: listPart.id,
|
||||
patch: { state: { status: "pending", input, raw: JSON.stringify(input) } },
|
||||
},
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: listPart.id, patch: toolRunning(listPart, dirName, t) },
|
||||
]
|
||||
return [events, {
|
||||
part: listPart, turn, title: dirName, startTime: t,
|
||||
completeOutput: "12 entries",
|
||||
completePatch: toolCompleted(listPart, dirName, "12 entries", t, t + 150),
|
||||
}]
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: listPart,
|
||||
turn,
|
||||
title: dirName,
|
||||
startTime: t,
|
||||
completeOutput: "12 entries",
|
||||
completePatch: toolCompleted(listPart, dirName, "12 entries", t, t + 150),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const fetchUrls = [
|
||||
|
|
@ -801,15 +863,26 @@ function buildWebFetchEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
|||
const events: TimelineEvent[] = [
|
||||
{ type: "part", part: fetchPart },
|
||||
{ type: "delay", ms: 60 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: fetchPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: fetchPart.id,
|
||||
patch: { state: { status: "pending", input, raw: JSON.stringify(input) } },
|
||||
},
|
||||
{ type: "delay", ms: 80 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: fetchPart.id, patch: toolRunning(fetchPart, url, t) },
|
||||
]
|
||||
return [events, {
|
||||
part: fetchPart, turn, title: url, startTime: t,
|
||||
completeOutput: "Fetched 24.3 KB",
|
||||
completePatch: toolCompleted(fetchPart, url, "Fetched 24.3 KB", t, t + 1200),
|
||||
}]
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: fetchPart,
|
||||
turn,
|
||||
title: url,
|
||||
startTime: t,
|
||||
completeOutput: "Fetched 24.3 KB",
|
||||
completePatch: toolCompleted(fetchPart, url, "Fetched 24.3 KB", t, t + 1200),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
||||
|
|
@ -831,8 +904,17 @@ function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
|||
{ type: "part", part: editPart },
|
||||
{ type: "delay", ms: 100 },
|
||||
{
|
||||
type: "part-update", messageID: turn.asstMsgID, partID: editPart.id, patch: {
|
||||
state: { status: "running", input: editPart.state.input, title: "bash.ts", metadata: { filediff, diagnostics: {} }, time: { start: t } },
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: editPart.id,
|
||||
patch: {
|
||||
state: {
|
||||
status: "running",
|
||||
input: editPart.state.input,
|
||||
title: "bash.ts",
|
||||
metadata: { filediff, diagnostics: {} },
|
||||
time: { start: t },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -845,11 +927,134 @@ function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
|||
time: { start: t, end: t + 300 },
|
||||
},
|
||||
}
|
||||
return [events, {
|
||||
part: editPart, turn, title: "bash.ts", startTime: t,
|
||||
completeOutput: "",
|
||||
completePatch,
|
||||
}]
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: editPart,
|
||||
turn,
|
||||
title: "bash.ts",
|
||||
startTime: t,
|
||||
completeOutput: "",
|
||||
completePatch,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildWriteEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
||||
const t = Date.now()
|
||||
const writeInput = {
|
||||
filePath: "/Users/kit/project/packages/opencode/src/util/helpers.ts",
|
||||
content: `export function sanitize(cmd: string): string {\n return cmd.replace(/[;&|]/g, "")\n}\n`,
|
||||
}
|
||||
const writePart = mkTool(turn.asstMsgID, "write", writeInput)
|
||||
const events: TimelineEvent[] = [
|
||||
{ type: "part", part: writePart },
|
||||
{ type: "delay", ms: 100 },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: writePart.id,
|
||||
patch: {
|
||||
state: {
|
||||
status: "running",
|
||||
input: writePart.state.input,
|
||||
title: "helpers.ts",
|
||||
metadata: {},
|
||||
time: { start: t },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
const completePatch = {
|
||||
state: {
|
||||
status: "completed",
|
||||
input: writeInput,
|
||||
title: "Created helpers.ts",
|
||||
metadata: {},
|
||||
time: { start: t, end: t + 300 },
|
||||
},
|
||||
}
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: writePart,
|
||||
turn,
|
||||
title: "helpers.ts",
|
||||
startTime: t,
|
||||
completeOutput: "",
|
||||
completePatch,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildApplyPatchEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
|
||||
const t = Date.now()
|
||||
const patchInput = {
|
||||
patch: `--- a/packages/opencode/src/tool/bash.ts\n+++ b/packages/opencode/src/tool/bash.ts\n@@ -1,3 +1,4 @@\n+import { sanitize } from "../util/helpers"\n const cmd = input.command\n const result = await run(cmd)\n return result\n--- a/packages/opencode/src/util/helpers.ts\n+++ b/packages/opencode/src/util/helpers.ts\n@@ -1,3 +1,5 @@\n export function sanitize(cmd: string): string {\n- return cmd.replace(/[;&|]/g, "")\n+ return cmd\n+ .replace(/[;&|]/g, "")\n+ .trim()\n }\n`,
|
||||
}
|
||||
const patchPart = mkTool(turn.asstMsgID, "apply_patch", patchInput)
|
||||
const files = [
|
||||
{
|
||||
filePath: "/Users/kit/project/packages/opencode/src/tool/bash.ts",
|
||||
relativePath: "packages/opencode/src/tool/bash.ts",
|
||||
type: "update",
|
||||
diff: "",
|
||||
before: "const cmd = input.command\nconst result = await run(cmd)\nreturn result",
|
||||
after:
|
||||
'import { sanitize } from "../util/helpers"\nconst cmd = input.command\nconst result = await run(cmd)\nreturn result',
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
{
|
||||
filePath: "/Users/kit/project/packages/opencode/src/util/helpers.ts",
|
||||
relativePath: "packages/opencode/src/util/helpers.ts",
|
||||
type: "update",
|
||||
diff: "",
|
||||
before: 'export function sanitize(cmd: string): string {\n return cmd.replace(/[;&|]/g, "")\n}',
|
||||
after:
|
||||
'export function sanitize(cmd: string): string {\n return cmd\n .replace(/[;&|]/g, "")\n .trim()\n}',
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
},
|
||||
]
|
||||
const events: TimelineEvent[] = [
|
||||
{ type: "part", part: patchPart },
|
||||
{ type: "delay", ms: 100 },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: patchPart.id,
|
||||
patch: {
|
||||
state: {
|
||||
status: "running",
|
||||
input: patchPart.state.input,
|
||||
title: "2 files",
|
||||
metadata: { files },
|
||||
time: { start: t },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
const completePatch = {
|
||||
state: {
|
||||
status: "completed",
|
||||
input: patchInput,
|
||||
title: "Applied patch to 2 files",
|
||||
metadata: { files },
|
||||
time: { start: t, end: t + 500 },
|
||||
},
|
||||
}
|
||||
return [
|
||||
events,
|
||||
{
|
||||
part: patchPart,
|
||||
turn,
|
||||
title: "2 files",
|
||||
startTime: t,
|
||||
completeOutput: "",
|
||||
completePatch,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildErrorEvents(turn: TurnState): TimelineEvent[] {
|
||||
|
|
@ -859,7 +1064,12 @@ function buildErrorEvents(turn: TurnState): TimelineEvent[] {
|
|||
return [
|
||||
{ type: "part", part: errPart },
|
||||
{ type: "delay", ms: 100 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: errPart.id, patch: toolRunning(errPart, input.command, t) },
|
||||
{
|
||||
type: "part-update",
|
||||
messageID: turn.asstMsgID,
|
||||
partID: errPart.id,
|
||||
patch: toolRunning(errPart, input.command, t),
|
||||
},
|
||||
{ type: "delay", ms: 200 },
|
||||
{
|
||||
type: "part-update",
|
||||
|
|
@ -941,7 +1151,8 @@ function SessionTimelineSimulator() {
|
|||
const [runningTool, setRunningTool] = createSignal<RunningTool | null>(null)
|
||||
|
||||
// Bash streaming state — tracks the current bash tool being streamed into
|
||||
let bashState: { cmdIdx: number; chunkIdx: number; currentOutput: string; part: ToolPart; turn: TurnState } | null = null
|
||||
let bashState: { cmdIdx: number; chunkIdx: number; currentOutput: string; part: ToolPart; turn: TurnState } | null =
|
||||
null
|
||||
|
||||
function startNewTurn() {
|
||||
turnCounter++
|
||||
|
|
@ -1009,6 +1220,71 @@ function SessionTimelineSimulator() {
|
|||
triggerTool(builder)
|
||||
}
|
||||
|
||||
function flow(turn: TurnState, build: (turn: TurnState) => [TimelineEvent[], RunningTool]) {
|
||||
const [evts, run] = build(turn)
|
||||
return [
|
||||
...evts,
|
||||
{ type: "delay", ms: 120 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: run.part.id, patch: run.completePatch },
|
||||
{ type: "delay", ms: 80 },
|
||||
]
|
||||
}
|
||||
|
||||
function shell(turn: TurnState) {
|
||||
const [evts, run, idx] = buildBashStartEvents(turn)
|
||||
const [a, out] = buildBashChunkEvents(turn, run.part, idx, 0, "")
|
||||
const [b] = buildBashChunkEvents(turn, run.part, idx, 1, out)
|
||||
return [
|
||||
...evts,
|
||||
{ type: "delay", ms: 120 },
|
||||
...a,
|
||||
{ type: "delay", ms: 80 },
|
||||
...b,
|
||||
{ type: "delay", ms: 80 },
|
||||
{ type: "part-update", messageID: turn.asstMsgID, partID: run.part.id, patch: run.completePatch },
|
||||
{ type: "delay", ms: 100 },
|
||||
]
|
||||
}
|
||||
|
||||
function pattern() {
|
||||
const prev = currentTurn()
|
||||
const turn = startNewTurn()
|
||||
const prompt = "Can you run one pass with every tool so I can preview the full timeline UI?"
|
||||
const evts: TimelineEvent[] = [...drainRunning()]
|
||||
if (prev) {
|
||||
evts.push(
|
||||
{ type: "message", message: mkAssistant(prev.asstMsgID, prev.userMsgID, Date.now()) },
|
||||
{ type: "status", status: { type: "idle" } },
|
||||
{ type: "delay", ms: 80 },
|
||||
)
|
||||
}
|
||||
evts.push(
|
||||
{ type: "status", status: { type: "busy" } },
|
||||
{ type: "message", message: mkUser(turn.userMsgID) },
|
||||
{ type: "part", part: mkText(turn.userMsgID, prompt) },
|
||||
{ type: "delay", ms: 120 },
|
||||
{ type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID) },
|
||||
{ type: "delay", ms: 100 },
|
||||
...flow(turn, buildReadEvents),
|
||||
...flow(turn, buildGrepEvents),
|
||||
...flow(turn, buildGlobEvents),
|
||||
...flow(turn, buildListEvents),
|
||||
...shell(turn),
|
||||
...flow(turn, buildWebFetchEvents),
|
||||
...flow(turn, buildEditEvents),
|
||||
...flow(turn, buildWriteEvents),
|
||||
...flow(turn, buildApplyPatchEvents),
|
||||
...buildTextEvents(turn),
|
||||
{ type: "delay", ms: 120 },
|
||||
{ type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID, Date.now()) },
|
||||
{ type: "status", status: { type: "idle" } },
|
||||
)
|
||||
setCurrentTurn(null)
|
||||
setRunningTool(null)
|
||||
bashState = null
|
||||
pb.appendAndPlay(evts)
|
||||
}
|
||||
|
||||
function completeTurn() {
|
||||
const turn = currentTurn()
|
||||
if (!turn) return
|
||||
|
|
@ -1042,15 +1318,22 @@ function SessionTimelineSimulator() {
|
|||
// --- Flat action list ---
|
||||
|
||||
const actions: Action[] = [
|
||||
{ key: "p", label: "Pattern", handler: () => pattern() },
|
||||
{ key: "e", label: "Explore", handler: () => triggerExplore() },
|
||||
{
|
||||
key: "b", label: "Bash", handler: () => {
|
||||
key: "b",
|
||||
label: "Bash",
|
||||
handler: () => {
|
||||
if (bashState) {
|
||||
// Already streaming — append next chunk
|
||||
const chunks = bashOutputChunks[bashState.cmdIdx]
|
||||
if (bashState.chunkIdx < chunks.length) {
|
||||
const [chunkEvents, newOutput] = buildBashChunkEvents(
|
||||
bashState.turn, bashState.part, bashState.cmdIdx, bashState.chunkIdx, bashState.currentOutput,
|
||||
bashState.turn,
|
||||
bashState.part,
|
||||
bashState.cmdIdx,
|
||||
bashState.chunkIdx,
|
||||
bashState.currentOutput,
|
||||
)
|
||||
bashState.chunkIdx++
|
||||
bashState.currentOutput = newOutput
|
||||
|
|
@ -1075,23 +1358,40 @@ function SessionTimelineSimulator() {
|
|||
},
|
||||
},
|
||||
{
|
||||
key: "t", label: "Text", handler: () => {
|
||||
key: "t",
|
||||
label: "Text",
|
||||
handler: () => {
|
||||
const drain = drainRunning()
|
||||
const turn = ensureTurn()
|
||||
pb.appendAndPlay([...drain, ...buildTextEvents(turn)])
|
||||
},
|
||||
},
|
||||
{ key: "d", label: "Edit", handler: () => triggerTool(buildEditEvents) },
|
||||
{
|
||||
key: "d",
|
||||
label: "Edit/Write/Patch",
|
||||
handler: (() => {
|
||||
const builders = [buildEditEvents, buildWriteEvents, buildApplyPatchEvents]
|
||||
let idx = 0
|
||||
return () => {
|
||||
triggerTool(builders[idx % builders.length]!)
|
||||
idx++
|
||||
}
|
||||
})(),
|
||||
},
|
||||
{ key: "w", label: "WebFetch", handler: () => triggerTool(buildWebFetchEvents) },
|
||||
{
|
||||
key: "x", label: "Error", handler: () => {
|
||||
key: "x",
|
||||
label: "Error",
|
||||
handler: () => {
|
||||
const drain = drainRunning()
|
||||
const turn = ensureTurn()
|
||||
pb.appendAndPlay([...drain, ...buildErrorEvents(turn)])
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "u", label: "User", handler: () => {
|
||||
key: "u",
|
||||
label: "User",
|
||||
handler: () => {
|
||||
const prev = currentTurn()
|
||||
const drain = drainRunning()
|
||||
// Complete previous turn if needed
|
||||
|
|
@ -1306,7 +1606,9 @@ function SessionTimelineSimulator() {
|
|||
|
||||
{/* Speed */}
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "4px", "flex-shrink": "0" }}>
|
||||
<span style={{ "font-size": "var(--font-size-small)", color: "var(--text-weak)", "margin-right": "2px" }}>Speed</span>
|
||||
<span style={{ "font-size": "var(--font-size-small)", color: "var(--text-weak)", "margin-right": "2px" }}>
|
||||
Speed
|
||||
</span>
|
||||
<For each={[0.25, 0.5, 1, 2, 4]}>
|
||||
{(s) => (
|
||||
<button
|
||||
|
|
@ -1316,8 +1618,7 @@ function SessionTimelineSimulator() {
|
|||
"font-size": "var(--font-size-small)",
|
||||
"font-family": "var(--font-family-mono)",
|
||||
"border-radius": "4px",
|
||||
border:
|
||||
"1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
|
||||
border: "1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
|
||||
background: pb.speed() === s ? "var(--color-blue, #3b82f6)" : "transparent",
|
||||
color: pb.speed() === s ? "white" : "var(--text-base)",
|
||||
cursor: "pointer",
|
||||
|
|
@ -1333,9 +1634,7 @@ function SessionTimelineSimulator() {
|
|||
{/* Trigger buttons */}
|
||||
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
|
||||
<For each={actions}>
|
||||
{(action) => (
|
||||
<TriggerBtn key={action.key} label={action.label} onClick={action.handler} />
|
||||
)}
|
||||
{(action) => <TriggerBtn key={action.key} label={action.label} onClick={action.handler} />}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
|
|
@ -1371,10 +1670,11 @@ Flat control panel — each action auto-completes the previous running tool.
|
|||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| p | Full pattern (user + every tool + text + completion) |
|
||||
| e | Explore (random read/grep/glob/list, stays running) |
|
||||
| b | Bash tool (keep pressing to stream output, other key completes) |
|
||||
| t | Stream text |
|
||||
| d | Edit tool (stays running) |
|
||||
| d | Edit/Write/Patch (cycles, stays running) |
|
||||
| x | Error tool |
|
||||
| u | New user turn |
|
||||
| c | Complete assistant turn |
|
||||
|
|
|
|||
|
|
@ -8,15 +8,17 @@ import { Icon } from "./icon"
|
|||
import { IconButton } from "./icon-button"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import {
|
||||
animate,
|
||||
clearFadeStyles,
|
||||
clearMaskStyles,
|
||||
GROW_SPRING,
|
||||
WIPE_MASK,
|
||||
} from "./motion"
|
||||
import { GROW_SPRING } from "./motion"
|
||||
import { useSpring } from "./motion-spring"
|
||||
import { busy, createThrottledValue, hold, updateScrollMask, useCollapsible, useToolFade } from "./tool-utils"
|
||||
import {
|
||||
busy,
|
||||
createThrottledValue,
|
||||
hold,
|
||||
updateScrollMask,
|
||||
useCollapsible,
|
||||
useRowWipe,
|
||||
useToolFade,
|
||||
} from "./tool-utils"
|
||||
|
||||
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
|
|
@ -65,7 +67,6 @@ function ShellRollingCommand(props: { text: string; animate?: boolean }) {
|
|||
|
||||
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const fade = 12
|
||||
const rows = 10
|
||||
const rowHeight = 22
|
||||
const max = rows * rowHeight
|
||||
|
|
@ -78,7 +79,7 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
|
|||
const [cap, setCap] = createSignal(max)
|
||||
|
||||
const updateMask = () => {
|
||||
if (scrollRef) updateScrollMask(scrollRef, fade)
|
||||
if (scrollRef) updateScrollMask(scrollRef)
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
|
|
@ -286,42 +287,11 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
|||
getKey={(row) => row.id}
|
||||
render={(row) => {
|
||||
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
|
||||
createEffect(() => {
|
||||
const el = textRef()
|
||||
if (!el || !row.text) return
|
||||
if (wiped.has(row.id)) return
|
||||
wiped.add(row.id)
|
||||
if (reduce()) return
|
||||
el.style.maskImage = WIPE_MASK
|
||||
el.style.webkitMaskImage = WIPE_MASK
|
||||
el.style.maskSize = "240% 100%"
|
||||
el.style.webkitMaskSize = "240% 100%"
|
||||
el.style.maskRepeat = "no-repeat"
|
||||
el.style.webkitMaskRepeat = "no-repeat"
|
||||
el.style.maskPosition = "100% 0%"
|
||||
el.style.webkitMaskPosition = "100% 0%"
|
||||
let done = false
|
||||
const clear = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearFadeStyles(el)
|
||||
clearMaskStyles(el)
|
||||
}
|
||||
const anim = animate(
|
||||
el,
|
||||
{
|
||||
opacity: [0, 1],
|
||||
filter: ["blur(2px)", "blur(0px)"],
|
||||
transform: ["translateX(-0.06em)", "translateX(0)"],
|
||||
maskPosition: "0% 0%",
|
||||
},
|
||||
GROW_SPRING,
|
||||
)
|
||||
anim.finished.catch(() => {}).finally(clear)
|
||||
onCleanup(() => {
|
||||
anim.stop()
|
||||
clear()
|
||||
})
|
||||
useRowWipe({
|
||||
id: () => row.id,
|
||||
text: () => row.text,
|
||||
ref: textRef,
|
||||
seen: wiped,
|
||||
})
|
||||
return (
|
||||
<div data-component="shell-rolling-row">
|
||||
|
|
|
|||
|
|
@ -108,54 +108,59 @@ export function useCollapsible(options: {
|
|||
}) {
|
||||
let heightAnim: AnimationPlaybackControls | undefined
|
||||
let fadeAnim: AnimationPlaybackControls | undefined
|
||||
let gen = 0
|
||||
|
||||
createEffect(
|
||||
on(options.open, (isOpen) => {
|
||||
const content = options.content()
|
||||
const body = options.body()
|
||||
if (!content || !body) return
|
||||
heightAnim?.stop()
|
||||
fadeAnim?.stop()
|
||||
if (isOpen) {
|
||||
content.style.display = ""
|
||||
content.style.height = "0px"
|
||||
body.style.opacity = "0"
|
||||
body.style.filter = "blur(2px)"
|
||||
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
|
||||
queueMicrotask(() => {
|
||||
if (!options.open()) return
|
||||
const c = options.content()
|
||||
if (!c) return
|
||||
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
|
||||
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
|
||||
heightAnim.finished
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (!options.open()) return
|
||||
const el = options.content()
|
||||
if (!el) return
|
||||
el.style.height = "auto"
|
||||
options.onOpen?.()
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
on(
|
||||
options.open,
|
||||
(isOpen) => {
|
||||
const content = options.content()
|
||||
const body = options.body()
|
||||
if (!content || !body) return
|
||||
heightAnim?.stop()
|
||||
fadeAnim?.stop()
|
||||
const id = ++gen
|
||||
if (isOpen) {
|
||||
content.style.display = ""
|
||||
content.style.height = "0px"
|
||||
body.style.opacity = "0"
|
||||
body.style.filter = "blur(2px)"
|
||||
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
|
||||
queueMicrotask(() => {
|
||||
if (gen !== id) return
|
||||
const c = options.content()
|
||||
if (!c) return
|
||||
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
|
||||
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
|
||||
heightAnim.finished.then(
|
||||
() => {
|
||||
if (gen !== id) return
|
||||
c.style.height = "auto"
|
||||
options.onOpen?.()
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const h = content.getBoundingClientRect().height
|
||||
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
|
||||
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
|
||||
heightAnim.finished
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (options.open()) return
|
||||
const el = options.content()
|
||||
if (!el) return
|
||||
el.style.display = "none"
|
||||
})
|
||||
}, { defer: true }),
|
||||
const h = content.getBoundingClientRect().height
|
||||
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
|
||||
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
|
||||
heightAnim.finished.then(
|
||||
() => {
|
||||
if (gen !== id) return
|
||||
content.style.display = "none"
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
++gen
|
||||
heightAnim?.stop()
|
||||
fadeAnim?.stop()
|
||||
})
|
||||
|
|
@ -170,6 +175,84 @@ export function useContextToolPending(parts: () => ToolPart[], working?: () => b
|
|||
return createMemo(() => !settled() && (!!working?.() || anyRunning()))
|
||||
}
|
||||
|
||||
export function useRowWipe(opts: {
|
||||
id: () => string
|
||||
text: () => string | undefined
|
||||
ref: () => HTMLElement | undefined
|
||||
seen: Set<string>
|
||||
}) {
|
||||
const reduce = prefersReducedMotion
|
||||
|
||||
createEffect(() => {
|
||||
const id = opts.id()
|
||||
const txt = opts.text()
|
||||
const el = opts.ref()
|
||||
if (!el) return
|
||||
if (!txt) {
|
||||
clearFadeStyles(el)
|
||||
clearMaskStyles(el)
|
||||
return
|
||||
}
|
||||
if (reduce() || typeof window === "undefined") {
|
||||
clearFadeStyles(el)
|
||||
clearMaskStyles(el)
|
||||
return
|
||||
}
|
||||
if (opts.seen.has(id)) {
|
||||
clearFadeStyles(el)
|
||||
clearMaskStyles(el)
|
||||
return
|
||||
}
|
||||
opts.seen.add(id)
|
||||
|
||||
el.style.maskImage = WIPE_MASK
|
||||
el.style.webkitMaskImage = WIPE_MASK
|
||||
el.style.maskSize = "240% 100%"
|
||||
el.style.webkitMaskSize = "240% 100%"
|
||||
el.style.maskRepeat = "no-repeat"
|
||||
el.style.webkitMaskRepeat = "no-repeat"
|
||||
el.style.maskPosition = "100% 0%"
|
||||
el.style.webkitMaskPosition = "100% 0%"
|
||||
el.style.opacity = "0"
|
||||
el.style.filter = "blur(2px)"
|
||||
el.style.transform = "translateX(-0.06em)"
|
||||
|
||||
let done = false
|
||||
const clear = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearFadeStyles(el)
|
||||
clearMaskStyles(el)
|
||||
}
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
let anim: AnimationPlaybackControls | undefined
|
||||
let frame: number | undefined = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
const node = opts.ref()
|
||||
if (!node) return
|
||||
anim = animate(
|
||||
node,
|
||||
{
|
||||
opacity: [0, 1],
|
||||
filter: ["blur(2px)", "blur(0px)"],
|
||||
transform: ["translateX(-0.06em)", "translateX(0)"],
|
||||
maskPosition: "0% 0%",
|
||||
},
|
||||
GROW_SPRING,
|
||||
)
|
||||
|
||||
anim.finished.catch(() => {}).finally(clear)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function useToolFade(
|
||||
ref: () => HTMLElement | undefined,
|
||||
options?: { delay?: number; wipe?: boolean; animate?: boolean },
|
||||
|
|
|
|||
Loading…
Reference in New Issue