diff options
| author | David Hill <[email protected]> | 2026-03-09 14:46:15 +0000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-09 09:46:15 -0500 |
| commit | 399b8f0701f04ded3114e81cd74e6024bef1a50d (patch) | |
| tree | 9814467362ba0dbc14d0faae54d5dd4fb51be794 | |
| parent | 3742e42fdf6e30573153c698572cbdd291df6e6d (diff) | |
| download | opencode-399b8f0701f04ded3114e81cd74e6024bef1a50d.tar.gz opencode-399b8f0701f04ded3114e81cd74e6024bef1a50d.zip | |
fix(app): session title turn spinner (#16764)
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 126 | ||||
| -rw-r--r-- | packages/ui/src/components/spinner.tsx | 1 | ||||
| -rw-r--r-- | packages/ui/src/styles/animations.css | 4 |
3 files changed, 94 insertions, 37 deletions
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ce6a01378..4060e5e5c 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -8,6 +8,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" +import { Spinner } from "@opencode-ai/ui/spinner" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" @@ -235,6 +236,40 @@ export function MessageTimeline(props: { if (!id) return idle return sync.data.session_status[id] ?? idle }) + const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") + + const [slot, setSlot] = createStore({ + open: false, + show: false, + fade: false, + }) + + let f: number | undefined + const clear = () => { + if (f !== undefined) window.clearTimeout(f) + f = undefined + } + + onCleanup(clear) + createEffect( + on( + working, + (on, prev) => { + clear() + if (on) { + setSlot({ open: true, show: true, fade: false }) + return + } + if (prev) { + setSlot({ open: false, show: true, fade: true }) + f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260) + return + } + setSlot({ open: false, show: false, fade: false }) + }, + { defer: true }, + ), + ) const activeMessageID = createMemo(() => { const parentID = pending()?.parentID if (parentID) { @@ -573,43 +608,64 @@ export function MessageTimeline(props: { aria-label={language.t("common.goBack")} /> </Show> - <Show when={titleValue() || title.editing}> - <Show - when={title.editing} - fallback={ - <h1 - class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" - onDblClick={openTitleEditor} - > - {titleValue()} - </h1> - } + <div class="flex items-center min-w-0 grow-1"> + <div + class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" + style={{ + width: slot.open ? "16px" : "0px", + "margin-right": slot.open ? "8px" : "0px", + }} + aria-hidden="true" > - <InlineInput - ref={(el) => { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} - /> + <Show when={slot.show}> + <div + class="transition-opacity duration-200 ease-out" + classList={{ + "opacity-0": slot.fade, + }} + > + <Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} /> + </div> + </Show> + </div> + <Show when={titleValue() || title.editing}> + <Show + when={title.editing} + fallback={ + <h1 + class="text-14-medium text-text-strong truncate grow-1 min-w-0" + onDblClick={openTitleEditor} + > + {titleValue()} + </h1> + } + > + <InlineInput + ref={(el) => { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + </Show> </Show> - </Show> + </div> </div> <Show when={sessionID()}> {(id) => ( diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx index cc4149d17..3d029d976 100644 --- a/packages/ui/src/components/spinner.tsx +++ b/packages/ui/src/components/spinner.tsx @@ -41,6 +41,7 @@ export function Spinner(props: { animation: square.corner ? undefined : `${square.outer ? "pulse-opacity-dim" : "pulse-opacity"} ${square.duration}s ease-in-out infinite`, + "animation-fill-mode": square.corner ? undefined : "both", "animation-delay": square.corner ? undefined : `${square.delay}s`, }} /> diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index f8d11e0e5..f9a09df37 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -26,10 +26,10 @@ @keyframes pulse-opacity-dim { 0%, 100% { - opacity: 0; + opacity: 0.15; } 50% { - opacity: 0.2; + opacity: 0.35; } } |
