summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2026-03-09 14:46:15 +0000
committerGitHub <[email protected]>2026-03-09 09:46:15 -0500
commit399b8f0701f04ded3114e81cd74e6024bef1a50d (patch)
tree9814467362ba0dbc14d0faae54d5dd4fb51be794
parent3742e42fdf6e30573153c698572cbdd291df6e6d (diff)
downloadopencode-399b8f0701f04ded3114e81cd74e6024bef1a50d.tar.gz
opencode-399b8f0701f04ded3114e81cd74e6024bef1a50d.zip
fix(app): session title turn spinner (#16764)
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx126
-rw-r--r--packages/ui/src/components/spinner.tsx1
-rw-r--r--packages/ui/src/styles/animations.css4
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;
}
}