Skip to content

Commit 4a819c7

Browse files
committed
delay animation until onboarding is complete
1 parent 503bd95 commit 4a819c7

6 files changed

Lines changed: 95 additions & 21 deletions

File tree

__tests__/formatters.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ describe("formatReasoningPreview", () => {
3535
it("truncates to last N characters when too long", () => {
3636
const longText = "a".repeat(150);
3737
const result = formatReasoningPreview(longText);
38-
expect(result.length).toBeLessThanOrEqual(120);
39-
expect(result).toBe("a".repeat(120));
38+
expect(result.length).toBeLessThanOrEqual(200);
39+
expect(result).toBe("a".repeat(150));
4040
});
4141
});
4242

src/app/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function App() {
7272
zIndex={controller.avatarLayerProps.zIndex}
7373
showBanner={controller.avatarLayerProps.showBanner}
7474
animateBanner={controller.avatarLayerProps.animateBanner}
75+
startupAnimationActive={controller.avatarLayerProps.startupAnimationActive}
7576
/>
7677

7778
{controller.isListeningDim ? (

src/app/components/AvatarLayer.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useCallback } from "react";
1+
import { memo, useCallback, useEffect, useRef } from "react";
22
import type { RefObject } from "react";
33
import type { DaemonAvatarRenderable } from "../../avatar/DaemonAvatarRenderable";
44
import { BANNER_GRADIENT, DAEMON_BANNER_LINES, useGlitchyBanner } from "../../hooks/use-glitchy-banner";
@@ -13,6 +13,7 @@ export interface AvatarLayerProps {
1313
zIndex?: number;
1414
showBanner?: boolean;
1515
animateBanner?: boolean;
16+
startupAnimationActive?: boolean;
1617
}
1718

1819
function AvatarLayerImpl(props: AvatarLayerProps) {
@@ -25,6 +26,7 @@ function AvatarLayerImpl(props: AvatarLayerProps) {
2526
zIndex = 0,
2627
showBanner = false,
2728
animateBanner = false,
29+
startupAnimationActive = false,
2830
} = props;
2931

3032
// Use glitchy banner animation when animateBanner is true
@@ -34,16 +36,47 @@ function AvatarLayerImpl(props: AvatarLayerProps) {
3436
const bannerLines = animateBanner ? glitchyBanner.lines : DAEMON_BANNER_LINES;
3537
const bannerColors = animateBanner ? glitchyBanner.colors : BANNER_GRADIENT;
3638

39+
// Keep a stable callback ref so we don't detach/reattach on daemonState changes.
40+
const daemonStateRef = useRef(daemonState);
41+
const applyAvatarForStateRef = useRef(applyAvatarForState);
42+
const startupAnimationActiveRef = useRef(startupAnimationActive);
43+
44+
useEffect(() => {
45+
daemonStateRef.current = daemonState;
46+
}, [daemonState]);
47+
useEffect(() => {
48+
applyAvatarForStateRef.current = applyAvatarForState;
49+
}, [applyAvatarForState]);
50+
useEffect(() => {
51+
startupAnimationActiveRef.current = startupAnimationActive;
52+
}, [startupAnimationActive]);
53+
3754
const handleAvatarRef = useCallback(
3855
(ref: DaemonAvatarRenderable | null) => {
56+
if (ref === avatarRef.current) return;
3957
avatarRef.current = ref;
40-
if (ref) {
41-
applyAvatarForState(daemonState);
58+
if (!ref) return;
59+
60+
applyAvatarForStateRef.current(daemonStateRef.current);
61+
if (startupAnimationActiveRef.current) {
62+
ref.resetSpawn();
63+
} else {
64+
ref.skipSpawn();
4265
}
4366
},
44-
[avatarRef, applyAvatarForState, daemonState]
67+
[avatarRef]
4568
);
4669

70+
useEffect(() => {
71+
const ref = avatarRef.current;
72+
if (!ref) return;
73+
if (startupAnimationActive) {
74+
ref.resetSpawn();
75+
} else {
76+
ref.skipSpawn();
77+
}
78+
}, [avatarRef, startupAnimationActive]);
79+
4780
return (
4881
<>
4982
{showBanner && (

src/avatar/DaemonAvatarRenderable.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import {
2+
type CliRenderer,
23
FrameBufferRenderable,
4+
OptimizedBuffer,
35
RGBA,
46
TextAttributes,
5-
OptimizedBuffer,
6-
type CliRenderer,
77
} from "@opentui/core";
88
import { SuperSampleType, ThreeCliRenderer } from "@opentui/core/3d";
99
import {
10-
createDaemonRig,
1110
type DaemonColorTheme,
1211
type DaemonRig,
1312
type ToolCategory,
13+
createDaemonRig,
1414
} from "./daemon-avatar-rig";
1515

1616
export type { ToolCategory } from "./daemon-avatar-rig";
@@ -30,6 +30,7 @@ export class DaemonAvatarRenderable extends FrameBufferRenderable {
3030
private pendingTheme: DaemonColorTheme | null = null;
3131
private pendingIntensity: { value: number; immediate: boolean } | null = null;
3232
private pendingAudioLevel: { value: number; immediate: boolean } | null = null;
33+
private pendingSpawnAction: "reset" | "skip" | null = null;
3334

3435
private getDesiredAspectRatio(width: number, height: number): number {
3536
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return 1;
@@ -138,6 +139,14 @@ export class DaemonAvatarRenderable extends FrameBufferRenderable {
138139
if (this.pendingAudioLevel) {
139140
this.rig.setAudioLevel(this.pendingAudioLevel.value, { immediate: this.pendingAudioLevel.immediate });
140141
}
142+
if (this.pendingSpawnAction) {
143+
if (this.pendingSpawnAction === "reset") {
144+
this.rig.resetSpawn();
145+
} else {
146+
this.rig.skipSpawn();
147+
}
148+
this.pendingSpawnAction = null;
149+
}
141150
this.renderBuffer = OptimizedBuffer.create(
142151
this.frameBuffer.width,
143152
this.frameBuffer.height,
@@ -340,4 +349,18 @@ export class DaemonAvatarRenderable extends FrameBufferRenderable {
340349
this.rig.triggerTypingPulse();
341350
}
342351
}
352+
353+
public resetSpawn(): void {
354+
this.pendingSpawnAction = "reset";
355+
if (this.rig) {
356+
this.rig.resetSpawn();
357+
}
358+
}
359+
360+
public skipSpawn(): void {
361+
this.pendingSpawnAction = "skip";
362+
if (this.rig) {
363+
this.rig.skipSpawn();
364+
}
365+
}
343366
}

src/avatar/rig/core/rig-engine.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { updateMainAnchor } from "../update/update-main-anchor";
1414
import { updateParticles } from "../update/update-particles";
1515
import { updateRings } from "../update/update-rings";
1616
import { updateSigils } from "../update/update-sigils";
17-
import { advanceSpawn, applySpawn } from "../update/update-spawn";
17+
import { advanceSpawn, applySpawn, resetSpawnState, skipSpawnAnimation } from "../update/update-spawn";
1818
import { clamp01 } from "../utils/math";
1919
import type { RigEngineOptions, RigEvent } from "./rig-types";
2020

@@ -178,6 +178,14 @@ export class RigEngine {
178178
this.state.intensity.spinBoost = Math.max(this.state.intensity.spinBoost, 1.5);
179179
}
180180

181+
public resetSpawn(): void {
182+
resetSpawnState(this.state);
183+
}
184+
185+
public skipSpawn(): void {
186+
skipSpawnAnimation(this.state);
187+
}
188+
181189
/** Returns the current spawn progress (0-1, where 1 = fully spawned) */
182190
public getSpawnProgress(): number {
183191
return this.state.spawn.progress;

src/hooks/use-app-controller.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface AppControllerResult {
3434
zIndex: number;
3535
showBanner: boolean;
3636
animateBanner: boolean;
37+
startupAnimationActive: boolean;
3738
};
3839
isListeningDim: boolean;
3940
listeningDimTop: number;
@@ -65,13 +66,6 @@ export function useAppController({
6566
const [isInitialLoad, setIsInitialLoad] = useState(true);
6667
const [startupIntroDone, setStartupIntroDone] = useState(false);
6768

68-
useEffect(() => {
69-
// Delay idle UI chrome (status/hotkeys) so the banner can resolve first.
70-
const delayMs = Math.max(0, STARTUP_BANNER_DURATION_MS - STARTUP_IDLE_CHROME_LEAD_MS);
71-
const t = setTimeout(() => setStartupIntroDone(true), delayMs);
72-
return () => clearTimeout(t);
73-
}, []);
74-
7569
// Update terminal size state on resize to trigger re-render
7670
useOnResize((width, height) => {
7771
setTerminalSize({ width, height });
@@ -141,6 +135,19 @@ export function useAppController({
141135
preferencesLoaded,
142136
showDeviceMenu,
143137
});
138+
const onboardingComplete = preferencesLoaded && !bootstrap.onboardingActive;
139+
140+
useEffect(() => {
141+
if (!onboardingComplete) {
142+
setStartupIntroDone(false);
143+
return;
144+
}
145+
// Delay idle UI chrome (status/hotkeys) so the banner can resolve first.
146+
const delayMs = Math.max(0, STARTUP_BANNER_DURATION_MS - STARTUP_IDLE_CHROME_LEAD_MS);
147+
setStartupIntroDone(false);
148+
const t = setTimeout(() => setStartupIntroDone(true), delayMs);
149+
return () => clearTimeout(t);
150+
}, [onboardingComplete]);
144151

145152
const daemon = useDaemonRuntimeController({
146153
currentModelId,
@@ -408,6 +415,8 @@ export function useAppController({
408415
}
409416
}, [daemon.hasInteracted, startupIntroDone]);
410417

418+
const startupAnimationActive = onboardingComplete && isInitialLoad;
419+
411420
const appContextValue = useAppContextBuilder({
412421
menus: {
413422
showDeviceMenu,
@@ -521,10 +530,10 @@ export function useAppController({
521530
height: avatarHeight,
522531
zIndex: isListening && daemon.hasInteracted ? 2 : 0,
523532
// Show banner only when idle, not interacted, and terminal is large enough
524-
// Banner is 8 lines tall and ~94 chars wide
525-
showBanner: !daemon.hasInteracted && terminalSize.height >= 30 && terminalSize.width >= 100,
526-
// Animate banner with glitch effect on initial app load
527-
animateBanner: isInitialLoad,
533+
showBanner:
534+
onboardingComplete && !daemon.hasInteracted && terminalSize.height >= 30 && terminalSize.width >= 100,
535+
animateBanner: startupAnimationActive,
536+
startupAnimationActive,
528537
},
529538
isListeningDim,
530539
listeningDimTop: statusBarHeight,

0 commit comments

Comments
 (0)