diff options
| author | Kit Langton <[email protected]> | 2026-03-02 17:24:32 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-02 16:24:32 -0600 |
| commit | 9d7852b5c39b64b96fe1bc14a80a344a0667f0b7 (patch) | |
| tree | bcbe5c2bac7e5dc203c5e1bf242a83ba5b32aaac /packages/app | |
| parent | 78069369e2253c9788c09b7a71478d140c9741f2 (diff) | |
| download | opencode-9d7852b5c39b64b96fe1bc14a80a344a0667f0b7.tar.gz opencode-9d7852b5c39b64b96fe1bc14a80a344a0667f0b7.zip | |
Animation Smorgasbord (#15637)
Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/app')
5 files changed, 381 insertions, 109 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ba5a33a47..89169af0d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,4 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" +import { useSpring } from "@opencode-ai/ui/motion-spring" import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" @@ -255,6 +256,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => { pendingAutoAccept: false, }) + const buttonsSpring = useSpring( + () => (store.mode === "normal" ? 1 : 0), + { visualDuration: 0.2, bounce: 0 }, + ) + const commentCount = createMemo(() => { if (store.mode === "shell") return 0 return prompt.context.items().filter((item) => !!item.comment?.trim()).length @@ -1250,10 +1256,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <div aria-hidden={store.mode !== "normal"} - class="flex items-center gap-1 transition-all duration-200 ease-out" - classList={{ - "opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal", - "opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal", + class="flex items-center gap-1" + style={{ + "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", }} > <TooltipKeybind @@ -1266,6 +1271,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => { type="button" variant="ghost" class="size-8 p-0" + style={{ + opacity: buttonsSpring(), + transform: `scale(${0.95 + buttonsSpring() * 0.05})`, + filter: `blur(${(1 - buttonsSpring()) * 2}px)`, + }} onClick={pick} disabled={store.mode !== "normal"} tabIndex={store.mode === "normal" ? undefined : -1} @@ -1303,6 +1313,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => { icon={working() ? "stop" : "arrow-up"} variant="primary" class="size-8" + style={{ + opacity: buttonsSpring(), + transform: `scale(${0.95 + buttonsSpring() * 0.05})`, + filter: `blur(${(1 - buttonsSpring()) * 2}px)`, + }} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> </Tooltip> @@ -1355,14 +1370,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Show when={store.mode === "normal" || store.mode === "shell"}> <DockTray attach="top"> <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0"> - <div class="flex items-center gap-1.5 min-w-0 flex-1"> - <Show when={store.mode === "shell"}> - <div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}> - <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> - <div class="size-4 shrink-0" /> - </div> - </Show> - <Show when={store.mode === "normal"}> + <div class="flex items-center gap-1.5 min-w-0 flex-1 relative"> + <div + class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0" + style={{ + padding: "0 4px 0 8px", + opacity: 1 - buttonsSpring(), + transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`, + filter: `blur(${buttonsSpring() * 2}px)`, + "pointer-events": buttonsSpring() < 0.5 ? "auto" : "none", + }} + > + <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> + <div class="size-4 shrink-0" /> + </div> + <div class="flex items-center gap-1.5 min-w-0 flex-1"> <TooltipKeybind placement="top" gutter={4} @@ -1376,7 +1398,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onSelect={local.agent.set} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" - triggerStyle={{ height: "28px" }} + triggerStyle={{ + height: "28px", + opacity: buttonsSpring(), + transform: `scale(${0.95 + buttonsSpring() * 0.05})`, + filter: `blur(${(1 - buttonsSpring()) * 2}px)`, + "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", + }} variant="ghost" /> </TooltipKeybind> @@ -1394,7 +1422,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => { variant="ghost" size="normal" class="min-w-0 max-w-[320px] text-13-regular group" - style={{ height: "28px" }} + style={{ + height: "28px", + opacity: buttonsSpring(), + transform: `scale(${0.95 + buttonsSpring() * 0.05})`, + filter: `blur(${(1 - buttonsSpring()) * 2}px)`, + "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", + }} onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} > <Show when={local.model.current()?.provider?.id}> @@ -1423,7 +1457,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => { triggerProps={{ variant: "ghost", size: "normal", - style: { height: "28px" }, + style: { + height: "28px", + opacity: buttonsSpring(), + transform: `scale(${0.95 + buttonsSpring() * 0.05})`, + filter: `blur(${(1 - buttonsSpring()) * 2}px)`, + "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", + }, class: "min-w-0 max-w-[320px] text-13-regular group", }} > @@ -1455,11 +1495,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" - triggerStyle={{ height: "28px" }} + triggerStyle={{ + height: "28px", + opacity: buttonsSpring(), + transform: `scale(${0.95 + buttonsSpring() * 0.05})`, + filter: `blur(${(1 - buttonsSpring()) * 2}px)`, + "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", + }} variant="ghost" /> </TooltipKeybind> - </Show> + </div> </div> <div class="shrink-0"> <RadioGroup diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index cfd78ece8..9f17d3f4b 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,5 +1,6 @@ -import { Show, createEffect, createMemo } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" +import { useSpring } from "@opencode-ai/ui/motion-spring" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -18,6 +19,23 @@ export function SessionComposerRegion(props: { onSubmit: () => void onResponseSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void + visualDuration?: number + bounce?: number + dockOpenVisualDuration?: number + dockOpenBounce?: number + dockCloseVisualDuration?: number + dockCloseBounce?: number + drawerExpandVisualDuration?: number + drawerExpandBounce?: number + drawerCollapseVisualDuration?: number + drawerCollapseBounce?: number + subtitleDuration?: number + subtitleTravel?: number + subtitleEdge?: number + countDuration?: number + countMask?: number + countMaskHeight?: number + countWidthDuration?: number }) { const params = useParams() const prompt = usePrompt() @@ -43,6 +61,40 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) + const open = createMemo(() => props.state.dock() && !props.state.closing()) + const config = createMemo(() => + open() + ? { + visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3, + bounce: props.dockOpenBounce ?? props.bounce ?? 0, + } + : { + visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3, + bounce: props.dockCloseBounce ?? props.bounce ?? 0, + }, + ) + const progress = useSpring( + () => (open() ? 1 : 0), + config, + ) + const value = createMemo(() => Math.max(0, Math.min(1, progress()))) + const [height, setHeight] = createSignal(320) + const dock = createMemo(() => props.state.dock() || value() > 0.001) + const full = createMemo(() => Math.max(78, height())) + const [contentRef, setContentRef] = createSignal<HTMLDivElement>() + + createEffect(() => { + const el = contentRef() + if (!el) return + const update = () => { + setHeight(el.getBoundingClientRect().height) + } + update() + const observer = new ResizeObserver(update) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + return ( <div ref={props.setPromptDockRef} @@ -87,30 +139,46 @@ export function SessionComposerRegion(props: { </div> } > - <Show when={props.state.dock()}> + <Show when={dock()}> <div classList={{ - "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true, - "max-h-[320px]": !props.state.closing(), - "max-h-0 pointer-events-none": props.state.closing(), - "opacity-0 translate-y-9": props.state.closing() || props.state.opening(), - "opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(), + "overflow-hidden": true, + "pointer-events-none": value() < 0.98, + }} + style={{ + "max-height": `${full() * value()}px`, }} > - <SessionTodoDock - todos={props.state.todos()} - title={language.t("session.todo.title")} - collapseLabel={language.t("session.todo.collapse")} - expandLabel={language.t("session.todo.expand")} - /> + <div ref={setContentRef}> + <SessionTodoDock + todos={props.state.todos()} + title={language.t("session.todo.title")} + collapseLabel={language.t("session.todo.collapse")} + expandLabel={language.t("session.todo.expand")} + dockProgress={value()} + visualDuration={props.visualDuration} + bounce={props.bounce} + expandVisualDuration={props.drawerExpandVisualDuration} + expandBounce={props.drawerExpandBounce} + collapseVisualDuration={props.drawerCollapseVisualDuration} + collapseBounce={props.drawerCollapseBounce} + subtitleDuration={props.subtitleDuration} + subtitleTravel={props.subtitleTravel} + subtitleEdge={props.subtitleEdge} + countDuration={props.countDuration} + countMask={props.countMask} + countMaskHeight={props.countMaskHeight} + countWidthDuration={props.countWidthDuration} + /> + </div> </div> </Show> <div classList={{ "relative z-10": true, - "transition-[margin] duration-[400ms] ease-out": true, - "-mt-9": props.state.dock() && !props.state.closing(), - "mt-0": !props.state.dock() || props.state.closing(), + }} + style={{ + "margin-top": `${-36 * value()}px`, }} > <PromptInput diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 201846177..03e96463c 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -29,7 +29,11 @@ export function createSessionComposerBlocked() { }) } -export function createSessionComposerState() { +export function createSessionComposerState( + options?: { + closeMs?: number | (() => number) + }, +) { const params = useParams() const sdk = useSDK() const sync = useSync() @@ -96,12 +100,19 @@ export function createSessionComposerState() { let timer: number | undefined let raf: number | undefined + const closeMs = () => { + const value = options?.closeMs + if (typeof value === "function") return Math.max(0, value()) + if (typeof value === "number") return Math.max(0, value) + return 400 + } + const scheduleClose = () => { if (timer) window.clearTimeout(timer) timer = window.setTimeout(() => { setStore({ dock: false, closing: false }) timer = undefined - }, 400) + }, closeMs()) } createEffect( diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index 279982760..ab8755aec 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -1,8 +1,12 @@ import type { Todo } from "@opencode-ai/sdk/v2" +import { AnimatedNumber } from "@opencode-ai/ui/animated-number" import { Checkbox } from "@opencode-ai/ui/checkbox" import { DockTray } from "@opencode-ai/ui/dock-surface" import { IconButton } from "@opencode-ai/ui/icon-button" -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { TextReveal } from "@opencode-ai/ui/text-reveal" +import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" +import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" function dot(status: Todo["status"]) { @@ -30,19 +34,35 @@ function dot(status: Todo["status"]) { ) } -export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) { +export function SessionTodoDock(props: { + todos: Todo[] + title: string + collapseLabel: string + expandLabel: string + dockProgress?: number + visualDuration?: number + bounce?: number + expandVisualDuration?: number + expandBounce?: number + collapseVisualDuration?: number + collapseBounce?: number + subtitleDuration?: number + subtitleTravel?: number + subtitleEdge?: number + countDuration?: number + countMask?: number + countMaskHeight?: number + countWidthDuration?: number +}) { const [store, setStore] = createStore({ collapsed: false, }) const toggle = () => setStore("collapsed", (value) => !value) - const summary = createMemo(() => { - const total = props.todos.length - if (total === 0) return "" - const completed = props.todos.filter((todo) => todo.status === "completed").length - return `${completed} of ${total} ${props.title.toLowerCase()} completed` - }) + const total = createMemo(() => props.todos.length) + const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) + const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`) const active = createMemo( () => @@ -53,56 +73,134 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL ) const preview = createMemo(() => active()?.content ?? "") + const config = createMemo(() => + store.collapsed + ? { + visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3, + bounce: props.collapseBounce ?? props.bounce ?? 0, + } + : { + visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3, + bounce: props.expandBounce ?? props.bounce ?? 0, + }, + ) + const collapse = useSpring(() => (store.collapsed ? 1 : 0), config) + const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1))) + const shut = createMemo(() => 1 - dock()) + const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) + const hide = createMemo(() => Math.max(value(), shut())) + const off = createMemo(() => hide() > 0.98) + const turn = createMemo(() => Math.max(0, Math.min(1, value()))) + const [height, setHeight] = createSignal(320) + const full = createMemo(() => Math.max(78, height())) + let contentRef: HTMLDivElement | undefined + + createEffect(() => { + const el = contentRef + if (!el) return + const update = () => { + setHeight(el.getBoundingClientRect().height) + } + update() + const observer = new ResizeObserver(update) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) return ( <DockTray data-component="session-todo-dock" - classList={{ - "h-[78px]": store.collapsed, + style={{ + "overflow-x": "visible", + "overflow-y": "hidden", + "max-height": `${Math.max(78, full() - value() * (full() - 78))}px`, }} > - <div - data-action="session-todo-toggle" - class="pl-3 pr-2 py-2 flex items-center gap-2" - role="button" - tabIndex={0} - onClick={toggle} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - toggle() - }} - > - <span class="text-14-regular text-text-strong cursor-default">{summary()}</span> - <Show when={store.collapsed}> - <div class="ml-1 flex-1 min-w-0"> - <Show when={preview()}> - <div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div> - </Show> - </div> - </Show> - <div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}> - <IconButton - data-action="session-todo-toggle-button" - icon="chevron-down" - size="normal" - variant="ghost" - classList={{ "rotate-180": store.collapsed }} - onMouseDown={(event) => { - event.preventDefault() - event.stopPropagation() + <div ref={contentRef}> + <div + data-action="session-todo-toggle" + class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible" + role="button" + tabIndex={0} + onClick={toggle} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + toggle() + }} + > + <span + class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible" + aria-label={label()} + style={{ + "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`, + "--tool-motion-mask": `${props.countMask ?? 18}%`, + "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`, + "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`, + opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, + filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`, }} - onClick={(event) => { - event.stopPropagation() - toggle() + > + <AnimatedNumber value={done()} /> + <span class="mx-1">of</span> + <AnimatedNumber value={total()} /> + <span> {props.title.toLowerCase()} completed</span> + </span> + <div + data-slot="session-todo-preview" + class="ml-1 min-w-0 overflow-hidden" + style={{ + flex: "1 1 auto", + "max-width": "100%", }} - aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} - /> + > + <TextReveal + class="text-14-regular text-text-base cursor-default" + text={store.collapsed ? preview() : undefined} + duration={props.subtitleDuration ?? 600} + travel={props.subtitleTravel ?? 25} + edge={props.subtitleEdge ?? 17} + spring="cubic-bezier(0.34, 1, 0.64, 1)" + springSoft="cubic-bezier(0.34, 1, 0.64, 1)" + growOnly + truncate + /> + </div> + <div class="ml-auto"> + <IconButton + data-action="session-todo-toggle-button" + data-collapsed={store.collapsed ? "true" : "false"} + icon="chevron-down" + size="normal" + variant="ghost" + style={{ transform: `rotate(${turn() * 180}deg)` }} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + toggle() + }} + aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} + /> + </div> </div> - </div> - <div data-slot="session-todo-list" hidden={store.collapsed}> - <TodoList todos={props.todos} open={!store.collapsed} /> + <div + data-slot="session-todo-list" + aria-hidden={store.collapsed || off()} + classList={{ + "pointer-events-none": hide() > 0.1, + }} + style={{ + visibility: off() ? "hidden" : "visible", + opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, + filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`, + }} + > + <TodoList todos={props.todos} open={!store.collapsed} /> + </div> </div> </DockTray> ) @@ -171,33 +269,43 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { }, 250) }} > - <For each={props.todos}> + <Index each={props.todos}> {(todo) => ( <Checkbox readOnly - checked={todo.status === "completed"} - indeterminate={todo.status === "in_progress"} - data-in-progress={todo.status === "in_progress" ? "" : undefined} - icon={dot(todo.status)} - style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }} + checked={todo().status === "completed"} + indeterminate={todo().status === "in_progress"} + data-in-progress={todo().status === "in_progress" ? "" : undefined} + data-state={todo().status} + icon={dot(todo().status)} + style={{ + "--checkbox-align": "flex-start", + "--checkbox-offset": "1px", + transition: + "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", + opacity: todo().status === "pending" ? "0.94" : "1", + filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", + }} > - <span + <TextStrikethrough + active={todo().status === "completed" || todo().status === "cancelled"} + text={todo().content} class="text-14-regular min-w-0 break-words" - classList={{ - "text-text-weak": todo.status === "completed" || todo.status === "cancelled", - "text-text-strong": todo.status !== "completed" && todo.status !== "cancelled", - }} style={{ "line-height": "var(--line-height-normal)", - "text-decoration": - todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined, + transition: + "color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", + color: + todo().status === "completed" || todo().status === "cancelled" + ? "var(--text-weak)" + : "var(--text-strong)", + opacity: todo().status === "pending" ? "0.92" : "1", + filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", }} - > - {todo.content} - </span> + /> </Checkbox> )} - </For> + </Index> </div> <div class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 7c711989e..0aa07bf74 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -10,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" +import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" @@ -31,6 +32,9 @@ type MessageComment = { } } +const emptyMessages: MessageType[] = [] +const idle = { type: "idle" as const } + const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { if (part.type !== "text" || !(part as TextPart).synthetic) return [] @@ -213,8 +217,34 @@ export function MessageTimeline(props: { const dialog = useDialog() const language = useLanguage() + const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) + const sessionMessages = createMemo(() => { + const id = sessionID() + if (!id) return emptyMessages + return sync.data.message[id] ?? emptyMessages + }) + const pending = createMemo(() => + sessionMessages().findLast( + (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", + ), + ) + const activeMessageID = createMemo(() => { + const parentID = pending()?.parentID + if (!parentID) return + + const messages = sessionMessages() + const result = Binary.search(messages, parentID, (message) => message.id) + const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) + if (!message || message.role !== "user") return + return message.id + }) + const sessionStatus = createMemo(() => { + const id = sessionID() + if (!id) return idle + return sync.data.session_status[id] ?? idle + }) const info = createMemo(() => { const id = sessionID() if (!id) return @@ -651,17 +681,23 @@ export function MessageTimeline(props: { </Button> </div> </Show> - <For each={staging.messages()}> - {(message) => { - const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? [])) + <For each={rendered()}> + {(messageID) => { + const active = createMemo(() => activeMessageID() === messageID) + const queued = createMemo(() => { + const item = pending() + if (!item || active()) return false + return messageID > item.id + }) + const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? [])) const commentCount = createMemo(() => comments().length) return ( <div - id={props.anchor(message.id)} - data-message-id={message.id} + id={props.anchor(messageID)} + data-message-id={messageID} ref={(el) => { - props.onRegisterMessage(el, message.id) - onCleanup(() => props.onUnregisterMessage(message.id)) + props.onRegisterMessage(el, messageID) + onCleanup(() => props.onUnregisterMessage(messageID)) }} classList={{ "min-w-0 w-full max-w-full": true, @@ -701,7 +737,10 @@ export function MessageTimeline(props: { </Show> <SessionTurn sessionID={sessionID() ?? ""} - messageID={message.id} + messageID={messageID} + active={active()} + queued={queued()} + status={active() ? sessionStatus() : undefined} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} |
