wip(desktop): session turn state consolidation

This commit is contained in:
Adam
2025-12-15 05:18:39 -06:00
parent ece3bfd93d
commit d81d63045a

View File

@@ -40,6 +40,9 @@ export function SessionTurn(
.sort((a, b) => a.id.localeCompare(b.id)),
)
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
if (!message()) return null
const status = createMemo(
() =>
data.store.session_status[props.sessionID] ?? {
@@ -49,91 +52,21 @@ export function SessionTurn(
const working = createMemo(() => status()?.type !== "idle")
let scrollRef: HTMLDivElement | undefined
const [state, setState] = createStore({
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
userScrolled: false,
stickyHeaderHeight: 0,
scrollY: 0,
autoScrolling: false,
})
function handleScroll() {
if (!scrollRef) return
// prevents scroll loops
if (working() && scrollRef.scrollTop < 100) return
setState("scrollY", scrollRef.scrollTop)
if (state.autoScrolling) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef
const atBottom = scrollHeight - scrollTop - clientHeight < 50
if (!atBottom && working()) {
setState("userScrolled", true)
}
}
function handleInteraction() {
if (working()) {
setState("userScrolled", true)
}
}
function scrollToBottom() {
if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return
setState("autoScrolling", true)
requestAnimationFrame(() => {
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
requestAnimationFrame(() => {
setState("autoScrolling", false)
})
})
}
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} style={{ "--scroll-y": `${state.scrollY}px` }}>
<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 parts = createMemo(() => data.store.part[message().id])
const parts = createMemo(() => data.store.part[message()!.id])
const lastTextPart = createMemo(() =>
assistantMessageParts()
.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,
)
const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text)
const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
const currentTask = createMemo(
@@ -154,10 +87,8 @@ export function SessionTurn(
let resolved = assistantParts()
const task = currentTask()
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
(m) => m.role === "assistant",
)
resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
}
return resolved
})
@@ -215,15 +146,75 @@ export function SessionTurn(
})
}
createEffect(() => {
lastPart()
scrollToBottom()
})
const [store, setStore] = createStore({
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
userScrolled: false,
stickyHeaderHeight: 0,
scrollY: 0,
autoScrolling: false,
status: rawStatus(),
stepsExpanded: true,
duration: duration(),
lastStatusChange: Date.now(),
statusTimeout: undefined as number | undefined,
})
function handleScroll() {
if (!scrollRef) return
// prevents scroll loops
if (working() && scrollRef.scrollTop < 100) return
setStore("scrollY", scrollRef.scrollTop)
if (store.autoScrolling) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef
const atBottom = scrollHeight - scrollTop - clientHeight < 50
if (!atBottom && working()) {
setStore("userScrolled", true)
}
}
function handleInteraction() {
if (working()) {
setStore("userScrolled", true)
}
}
function scrollToBottom() {
if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return
setStore("autoScrolling", true)
requestAnimationFrame(() => {
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
requestAnimationFrame(() => {
setStore("autoScrolling", false)
})
})
}
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(() => {
lastPart()
scrollToBottom()
})
createEffect(() => {
@@ -233,56 +224,60 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === store.status || !newStatus) return
const timeSinceLastChange = Date.now() - lastStatusChange
const timeSinceLastChange = Date.now() - store.lastStatusChange
if (timeSinceLastChange >= 2500) {
setStore("status", newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
setStore("lastStatusChange", Date.now())
if (store.statusTimeout) {
clearTimeout(store.statusTimeout)
setStore("statusTimeout", undefined)
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
if (store.statusTimeout) clearTimeout(store.statusTimeout)
setStore(
"statusTimeout",
setTimeout(() => {
setStore("status", rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 2500 - timeSinceLastChange) as unknown as number
setStore("lastStatusChange", Date.now())
setStore("statusTimeout", undefined)
}, 2500 - timeSinceLastChange) as unknown as number,
)
}
})
createEffect((prev) => {
const isWorking = working()
if (prev && !isWorking && !state.userScrolled) {
if (prev && !isWorking && !store.userScrolled) {
setStore("stepsExpanded", false)
}
return isWorking
}, working())
return (
<div data-component="session-turn" class={props.classes?.root} style={{ "--scroll-y": `${store.scrollY}px` }}>
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
<div onClick={handleInteraction}>
<div
data-message={message().id}
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` }}
>
{/* 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>
<Match when={working()}>
<Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
<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}</h1>
</Match>
</Switch>
</div>
@@ -290,10 +285,10 @@ export function SessionTurn(
</div>
{/* User Message */}
<div data-slot="session-turn-message-content">
<Message message={message()} parts={parts()} />
<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-slot="session-turn-collapsible-trigger-content"
variant="ghost"
@@ -327,10 +322,7 @@ export function SessionTurn(
return (
<Switch>
<Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
<Message
message={assistantMessage}
parts={parts().filter((p) => p?.id !== last()?.id)}
/>
<Message message={assistantMessage} parts={parts().filter((p) => p?.id !== last()?.id)} />
</Match>
<Match when={true}>
<Message message={assistantMessage} parts={parts()} />
@@ -352,7 +344,7 @@ export function SessionTurn(
<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={message()!.summary?.diffs?.length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
@@ -360,24 +352,21 @@ export function SessionTurn(
{(summary) => (
<Markdown
data-slot="session-turn-markdown"
data-diffs={!!message().summary?.diffs?.length}
data-diffs={!!message()!.summary?.diffs?.length}
text={summary()}
/>
)}
</Show>
</div>
<Accordion data-slot="session-turn-accordion" multiple>
<For each={message().summary?.diffs ?? []}>
<For each={message()!.summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-accordion-trigger-content">
<div data-slot="session-turn-file-info">
<FileIcon
node={{ path: diff.file, type: "file" }}
data-slot="session-turn-file-icon"
/>
<FileIcon node={{ path: diff.file, type: "file" }} data-slot="session-turn-file-icon" />
<div data-slot="session-turn-file-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
@@ -419,9 +408,6 @@ export function SessionTurn(
</Card>
</Show>
</div>
)
}}
</Show>
{props.children}
</div>
</div>