fix(desktop): rendering shell mode messages
This commit is contained in:
@@ -152,9 +152,22 @@
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
|
||||
[data-component="markdown"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
&[data-scrollable] {
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface MessagePartProps {
|
||||
part: PartType
|
||||
message: MessageType
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
export type PartComponent = Component<MessagePartProps>
|
||||
@@ -208,7 +209,13 @@ export function Part(props: MessagePartProps) {
|
||||
const component = createMemo(() => PART_MAPPING[props.part.type])
|
||||
return (
|
||||
<Show when={component()}>
|
||||
<Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
|
||||
<Dynamic
|
||||
component={component()}
|
||||
part={props.part}
|
||||
message={props.message}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -219,6 +226,7 @@ export interface ToolProps {
|
||||
tool: string
|
||||
output?: string
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
export type ToolComponent = Component<ToolProps>
|
||||
@@ -286,6 +294,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
metadata={metadata}
|
||||
output={part.state.status === "completed" ? part.state.output : undefined}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -326,6 +335,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="glasses"
|
||||
trigger={{
|
||||
title: "Read",
|
||||
@@ -340,7 +350,11 @@ ToolRegistry.register({
|
||||
name: "list",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="bullet-list"
|
||||
trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
{(output) => (
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
@@ -358,6 +372,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Glob",
|
||||
@@ -385,6 +400,7 @@ ToolRegistry.register({
|
||||
if (props.input.include) args.push("include=" + props.input.include)
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Grep",
|
||||
@@ -409,6 +425,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="window-cursor"
|
||||
trigger={{
|
||||
title: "Webfetch",
|
||||
@@ -438,6 +455,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="task"
|
||||
trigger={{
|
||||
title: `${props.input.subagent_type || props.tool} Agent`,
|
||||
@@ -462,6 +480,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: "Shell",
|
||||
@@ -485,6 +504,7 @@ ToolRegistry.register({
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
@@ -534,6 +554,7 @@ ToolRegistry.register({
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
@@ -575,6 +596,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
icon="checklist"
|
||||
trigger={{
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Swi
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Typewriter } from "./typewriter"
|
||||
import { Message } from "./message-part"
|
||||
import { Message, Part } from "./message-part"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
@@ -35,134 +35,42 @@ export function SessionTurn(
|
||||
) {
|
||||
const data = useData()
|
||||
const diffComponent = useDiffComponent()
|
||||
const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
|
||||
const messages = createMemo(() => data.store.message[props.sessionID] ?? [])
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
|
||||
const lastUserMessage = createMemo(() => userMessages().at(-1)!)
|
||||
const message = createMemo(() => userMessages().find((m) => m.id === props.messageID)!)
|
||||
const status = createMemo(
|
||||
() =>
|
||||
data.store.session_status[props.sessionID] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle" && message()?.id === userMessages().at(-1)?.id)
|
||||
const working = createMemo(() => status().type !== "idle" && message().id === lastUserMessage().id)
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
let lastScrollTop = 0
|
||||
const [state, setState] = createStore({
|
||||
contentRef: undefined as HTMLDivElement | undefined,
|
||||
stickyTitleRef: undefined as HTMLDivElement | undefined,
|
||||
stickyTriggerRef: undefined as HTMLDivElement | undefined,
|
||||
autoScrolled: false,
|
||||
userScrolled: false,
|
||||
stickyHeaderHeight: 0,
|
||||
retrySeconds: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const r = retry()
|
||||
if (!r) {
|
||||
setState("retrySeconds", 0)
|
||||
return
|
||||
}
|
||||
const updateSeconds = () => {
|
||||
const next = r.next
|
||||
if (next) setState("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
|
||||
}
|
||||
updateSeconds()
|
||||
|
||||
const timer = setInterval(updateSeconds, 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollRef || state.autoScrolled) return
|
||||
const { scrollTop } = scrollRef
|
||||
// only mark as user scrolled if they actively scrolled upward
|
||||
// content growth increases scrollHeight but never decreases scrollTop
|
||||
const scrolledUp = scrollTop < lastScrollTop - 10
|
||||
if (scrolledUp && working()) {
|
||||
setState("userScrolled", true)
|
||||
}
|
||||
lastScrollTop = scrollTop
|
||||
}
|
||||
|
||||
function handleInteraction() {
|
||||
if (working()) {
|
||||
setState("userScrolled", true)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!scrollRef || state.userScrolled || !working()) return
|
||||
setState("autoScrolled", true)
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
|
||||
requestAnimationFrame(() => {
|
||||
lastScrollTop = scrollRef?.scrollTop ?? 0
|
||||
setState("autoScrolled", false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
createResizeObserver(() => state.contentRef, scrollToBottom)
|
||||
|
||||
createEffect(() => {
|
||||
if (!working()) {
|
||||
setState("userScrolled", false)
|
||||
}
|
||||
})
|
||||
|
||||
createResizeObserver(
|
||||
() => state.stickyTitleRef,
|
||||
({ height }) => {
|
||||
const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0
|
||||
setState("stickyHeaderHeight", height + triggerHeight + 8)
|
||||
},
|
||||
)
|
||||
|
||||
createResizeObserver(
|
||||
() => state.stickyTriggerRef,
|
||||
({ height }) => {
|
||||
const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0
|
||||
setState("stickyHeaderHeight", titleHeight + height + 8)
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
|
||||
<div onClick={handleInteraction}>
|
||||
<Show when={message()}>
|
||||
{(message) => {
|
||||
const assistantMessages = createMemo(() => {
|
||||
return messages()?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message().id,
|
||||
) as AssistantMessage[]
|
||||
return messages().filter((m) => m.role === "assistant" && m.parentID == message().id) as AssistantMessage[]
|
||||
})
|
||||
const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1))
|
||||
const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
|
||||
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
|
||||
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
|
||||
const parts = createMemo(() => data.store.part[message().id])
|
||||
const lastTextPart = createMemo(() =>
|
||||
assistantMessageParts()
|
||||
assistantParts()
|
||||
.filter((p) => p?.type === "text")
|
||||
?.at(-1),
|
||||
)
|
||||
const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text)
|
||||
const lastTextPartShown = createMemo(
|
||||
() => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0,
|
||||
.at(-1),
|
||||
)
|
||||
const summary = createMemo(() => message().summary?.body)
|
||||
const response = createMemo(() => lastTextPart()?.text)
|
||||
|
||||
const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
|
||||
const currentTask = createMemo(
|
||||
() =>
|
||||
assistantParts().findLast(
|
||||
@@ -226,10 +134,19 @@ export function SessionTurn(
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const hasDiffs = createMemo(() => message().summary?.diffs?.length)
|
||||
const isShellMode = createMemo(() => {
|
||||
if (parts().some((p) => p.type !== "text" || !p.synthetic)) return false
|
||||
if (assistantParts().length !== 1) return false
|
||||
const assistantPart = assistantParts()[0]
|
||||
if (assistantPart.type !== "tool") return false
|
||||
if (assistantPart.tool !== "bash") return false
|
||||
return true
|
||||
})
|
||||
|
||||
function duration() {
|
||||
const completed = lastAssistantMessage()?.time.completed
|
||||
const from = DateTime.fromMillis(message()!.time.created)
|
||||
const from = DateTime.fromMillis(message().time.created)
|
||||
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
|
||||
const interval = Interval.fromDateTimes(from, to)
|
||||
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
|
||||
@@ -242,12 +159,86 @@ export function SessionTurn(
|
||||
})
|
||||
}
|
||||
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
let lastScrollTop = 0
|
||||
const [store, setStore] = createStore({
|
||||
contentRef: undefined as HTMLDivElement | undefined,
|
||||
stickyTitleRef: undefined as HTMLDivElement | undefined,
|
||||
stickyTriggerRef: undefined as HTMLDivElement | undefined,
|
||||
autoScrolled: false,
|
||||
userScrolled: false,
|
||||
stickyHeaderHeight: 0,
|
||||
retrySeconds: 0,
|
||||
status: rawStatus(),
|
||||
stepsExpanded: props.stepsExpanded ?? working(),
|
||||
duration: duration(),
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const r = retry()
|
||||
if (!r) {
|
||||
setStore("retrySeconds", 0)
|
||||
return
|
||||
}
|
||||
const updateSeconds = () => {
|
||||
const next = r.next
|
||||
if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
|
||||
}
|
||||
updateSeconds()
|
||||
const timer = setInterval(updateSeconds, 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollRef || store.autoScrolled) return
|
||||
const { scrollTop } = scrollRef
|
||||
// only mark as user scrolled if they actively scrolled upward
|
||||
// content growth increases scrollHeight but never decreases scrollTop
|
||||
const scrolledUp = scrollTop < lastScrollTop - 10
|
||||
if (scrolledUp && working()) {
|
||||
setStore("userScrolled", true)
|
||||
}
|
||||
lastScrollTop = scrollTop
|
||||
}
|
||||
|
||||
function handleInteraction() {
|
||||
if (working()) setStore("userScrolled", true)
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!scrollRef || store.userScrolled || !working()) return
|
||||
setStore("autoScrolled", true)
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
|
||||
requestAnimationFrame(() => {
|
||||
lastScrollTop = scrollRef?.scrollTop ?? 0
|
||||
setStore("autoScrolled", false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
createResizeObserver(() => store.contentRef, scrollToBottom)
|
||||
|
||||
createEffect(() => {
|
||||
if (!working()) setStore("userScrolled", false)
|
||||
})
|
||||
|
||||
createResizeObserver(
|
||||
() => store.stickyTitleRef,
|
||||
({ height }) => {
|
||||
const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
|
||||
setStore("stickyHeaderHeight", height + triggerHeight + 8)
|
||||
},
|
||||
)
|
||||
|
||||
createResizeObserver(
|
||||
() => store.stickyTriggerRef,
|
||||
({ height }) => {
|
||||
const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
|
||||
setStore("stickyHeaderHeight", titleHeight + height + 8)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (props.stepsExpanded !== undefined) {
|
||||
setStore("stepsExpanded", props.stepsExpanded)
|
||||
@@ -292,7 +283,7 @@ export function SessionTurn(
|
||||
setStore("stepsExpanded", true)
|
||||
props.onStepsExpandedChange?.(true)
|
||||
}
|
||||
if (prev && !isWorking && !state.userScrolled) {
|
||||
if (prev && !isWorking && !store.userScrolled) {
|
||||
setStore("stepsExpanded", false)
|
||||
props.onStepsExpandedChange?.(false)
|
||||
}
|
||||
@@ -300,15 +291,23 @@ export function SessionTurn(
|
||||
}, working())
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
|
||||
<div onClick={handleInteraction}>
|
||||
<div
|
||||
ref={(el) => setState("contentRef", el)}
|
||||
ref={(el) => setStore("contentRef", el)}
|
||||
data-message={message().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
style={{ "--sticky-header-height": `${state.stickyHeaderHeight}px` }}
|
||||
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={isShellMode()}>
|
||||
<Part part={assistantParts()[0]} message={message()} defaultOpen />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{/* Title (sticky) */}
|
||||
<div ref={(el) => setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
|
||||
<div ref={(el) => setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
|
||||
<div data-slot="session-turn-message-header">
|
||||
<div data-slot="session-turn-message-title">
|
||||
<Switch>
|
||||
@@ -316,7 +315,7 @@ export function SessionTurn(
|
||||
<Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<h1>{message().summary?.title}</h1>
|
||||
<h1>{message().summary?.title ?? "New message"}</h1>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
@@ -327,7 +326,7 @@ export function SessionTurn(
|
||||
<Message message={message()} parts={parts()} />
|
||||
</div>
|
||||
{/* Trigger (sticky) */}
|
||||
<div ref={(el) => setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
|
||||
<div ref={(el) => setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
|
||||
<Button
|
||||
data-expandable={assistantMessages().length > 0}
|
||||
data-slot="session-turn-collapsible-trigger-content"
|
||||
@@ -353,7 +352,7 @@ export function SessionTurn(
|
||||
})()}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-seconds">
|
||||
· retrying {state.retrySeconds > 0 ? `in ${state.retrySeconds}s ` : ""}
|
||||
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
|
||||
</Match>
|
||||
@@ -381,11 +380,8 @@ export function SessionTurn(
|
||||
)
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
|
||||
<Message
|
||||
message={assistantMessage}
|
||||
parts={parts().filter((p) => p?.id !== last()?.id)}
|
||||
/>
|
||||
<Match when={response() && lastTextPart()?.id === last()?.id}>
|
||||
<Message message={assistantMessage} parts={parts().filter((p) => p?.id !== last()?.id)} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Message message={assistantMessage} parts={parts()} />
|
||||
@@ -405,21 +401,24 @@ export function SessionTurn(
|
||||
<Show when={!working()}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<h2 data-slot="session-turn-summary-title">
|
||||
<Switch>
|
||||
<Match when={message().summary?.diffs?.length}>Summary</Match>
|
||||
<Match when={true}>Response</Match>
|
||||
</Switch>
|
||||
</h2>
|
||||
<Show when={summary()}>
|
||||
<Match when={summary()}>
|
||||
{(summary) => (
|
||||
<Markdown
|
||||
data-slot="session-turn-markdown"
|
||||
data-diffs={!!message().summary?.diffs?.length}
|
||||
text={summary()}
|
||||
/>
|
||||
<>
|
||||
<h2 data-slot="session-turn-summary-title">Summary</h2>
|
||||
<Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={summary()} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={response()}>
|
||||
{(response) => (
|
||||
<>
|
||||
<h2 data-slot="session-turn-summary-title">Response</h2>
|
||||
<Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response()} />
|
||||
</>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Accordion data-slot="session-turn-accordion" multiple>
|
||||
<For each={message().summary?.diffs ?? []}>
|
||||
@@ -473,10 +472,9 @@ export function SessionTurn(
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user