Skip to content

Commit 605dd61

Browse files
jeremyederAmbient Code Botclaude
authored
feat(frontend): add runtime-configurable loading tips with branded dots (#817)
## Summary - Replace joke loading messages with educational tips about ACP features - Add 4 branded loading dots (#0066B1, #522DAE, #F40000, white) at 2x size - Make tips runtime-configurable via `LOADING_TIPS` environment variable - Add markdown link support in tips using `[text](url)` syntax ## Changes - **New API endpoint**: `/api/config/loading-tips` serves tips from env var with fallback - **New hook**: `useLoadingTips()` with React Query caching (staleTime: Infinity) - **Shared constants**: `lib/loading-tips.ts` for default tips - **Updated LoadingDots**: 4 colored dots, markdown link parsing, improved contrast ## Configuration Set `LOADING_TIPS` env var as JSON array: ```bash LOADING_TIPS='["Tip: First tip", "Tip: Second tip", "[Link text](https://example.com)"]' ``` Or use a ConfigMap (example in artifacts folder). ## Test plan - [ ] Verify loading dots appear during AI response generation - [ ] Verify tips rotate every 8 seconds - [ ] Test with `LOADING_TIPS` env var set to custom JSON - [ ] Test markdown links render as clickable - [ ] Verify fallback to defaults when env var not set 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Ambient Code Bot <bot@ambient-code.local> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1cb585a commit 605dd61

7 files changed

Lines changed: 171 additions & 34 deletions

File tree

components/frontend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ IMAGE_COMPRESSION_TARGET=358400
4242
# This is only used for strategy constraints that check context.environment.
4343
# NEXT_PUBLIC_UNLEASH_ENV_CONTEXT_FIELD=development
4444

45+
# Loading tips shown during AI response generation (optional)
46+
# JSON array of strings. If not set, defaults to built-in tips.
47+
# Example: LOADING_TIPS='["Tip: First tip here", "Tip: Second tip here"]'
48+
# LOADING_TIPS=
49+
4550
# Langfuse Configuration for User Feedback
4651
# These are used by the /api/feedback route to submit user feedback scores
4752
# Get your keys from your Langfuse instance: Settings > API Keys
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { env } from '@/lib/env';
2+
import { DEFAULT_LOADING_TIPS } from '@/lib/loading-tips';
3+
4+
export async function GET() {
5+
let tips = DEFAULT_LOADING_TIPS;
6+
7+
if (env.LOADING_TIPS) {
8+
try {
9+
const parsed = JSON.parse(env.LOADING_TIPS);
10+
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(t => typeof t === 'string')) {
11+
tips = parsed;
12+
}
13+
} catch {
14+
// Invalid JSON, fall back to defaults
15+
console.warn('LOADING_TIPS environment variable contains invalid JSON, using defaults');
16+
}
17+
}
18+
19+
return Response.json({ tips });
20+
}

components/frontend/src/components/ui/message.tsx

Lines changed: 87 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import ReactMarkdown from "react-markdown";
66
import remarkGfm from "remark-gfm";
77
import type { Components } from "react-markdown";
88
import { formatTimestamp } from "@/lib/format-timestamp";
9+
import { useLoadingTips } from "@/services/queries/use-loading-tips";
10+
import { DEFAULT_LOADING_TIPS } from "@/lib/loading-tips";
911

1012
export type MessageRole = "bot" | "user";
1113

@@ -97,39 +99,78 @@ const defaultComponents: Components = {
9799
),
98100
};
99101

100-
const LOADING_MESSAGES = [
101-
"Pretending to be productive",
102-
"Downloading more RAM",
103-
"Consulting the magic 8-ball",
104-
"Teaching bugs to behave",
105-
"Brewing digital coffee",
106-
"Rolling for initiative",
107-
"Surfing the data waves",
108-
"Juggling bits and bytes",
109-
"Tipping my fedora",
110-
"Reticulating splines",
111-
];
102+
/**
103+
* Parse markdown-style links [text](url) in a string and return React elements
104+
*/
105+
function parseMarkdownLinks(text: string): React.ReactNode {
106+
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
107+
const parts: React.ReactNode[] = [];
108+
let lastIndex = 0;
109+
let match;
110+
111+
while ((match = linkRegex.exec(text)) !== null) {
112+
// Add text before the link
113+
if (match.index > lastIndex) {
114+
parts.push(text.slice(lastIndex, match.index));
115+
}
116+
// Validate URL scheme to prevent javascript: injection
117+
const href = match[2];
118+
const isSafeUrl = href.startsWith('https://') || href.startsWith('http://');
119+
// Add the link (or plain text if URL is unsafe)
120+
parts.push(
121+
isSafeUrl ? (
122+
<a
123+
key={match.index}
124+
href={href}
125+
target="_blank"
126+
rel="noopener noreferrer"
127+
className="text-primary hover:underline"
128+
>
129+
{match[1]}
130+
</a>
131+
) : (
132+
<span key={match.index}>{match[1]}</span>
133+
)
134+
);
135+
lastIndex = match.index + match[0].length;
136+
}
137+
138+
// Add remaining text after last link
139+
if (lastIndex < text.length) {
140+
parts.push(text.slice(lastIndex));
141+
}
142+
143+
return parts.length > 0 ? parts : text;
144+
}
112145

113146
export const LoadingDots = () => {
147+
const { data } = useLoadingTips();
148+
const tips = data?.tips ?? DEFAULT_LOADING_TIPS;
149+
114150
const [messageIndex, setMessageIndex] = React.useState(() =>
115-
Math.floor(Math.random() * LOADING_MESSAGES.length)
151+
Math.floor(Math.random() * tips.length)
116152
);
117153

154+
// Reset index when tips array changes to prevent undefined access
155+
React.useEffect(() => {
156+
setMessageIndex((prev) => prev % tips.length);
157+
}, [tips.length]);
158+
118159
React.useEffect(() => {
119160
const intervalId = setInterval(() => {
120-
setMessageIndex((prevIndex) => (prevIndex + 1) % LOADING_MESSAGES.length);
161+
setMessageIndex((prevIndex) => (prevIndex + 1) % tips.length);
121162
}, 8000);
122163
return () => clearInterval(intervalId);
123-
}, []);
164+
}, [tips.length]);
124165

125166
return (
126167
<div className="flex items-center mt-2">
127168
<svg
128-
width="24"
129-
height="8"
130-
viewBox="0 0 24 8"
169+
width="56"
170+
height="16"
171+
viewBox="0 0 56 16"
131172
xmlns="http://www.w3.org/2000/svg"
132-
className="mr-2 text-primary"
173+
className="mr-2"
133174
>
134175
<style>
135176
{`
@@ -148,36 +189,48 @@ export const LoadingDots = () => {
148189
animation-delay: 0s;
149190
}
150191
.loading-dot-2 {
151-
animation-delay: 0.2s;
192+
animation-delay: 0.15s;
152193
}
153194
.loading-dot-3 {
154-
animation-delay: 0.4s;
195+
animation-delay: 0.3s;
196+
}
197+
.loading-dot-4 {
198+
animation-delay: 0.45s;
155199
}
156200
`}
157201
</style>
158202
<circle
159203
className="loading-dot loading-dot-1"
160-
cx="4"
161-
cy="4"
162-
r="3"
163-
fill="currentColor"
204+
cx="8"
205+
cy="8"
206+
r="6"
207+
fill="#0066B1"
164208
/>
165209
<circle
166210
className="loading-dot loading-dot-2"
167-
cx="12"
168-
cy="4"
169-
r="3"
170-
fill="currentColor"
211+
cx="22"
212+
cy="8"
213+
r="6"
214+
fill="#522DAE"
171215
/>
172216
<circle
173217
className="loading-dot loading-dot-3"
174-
cx="20"
175-
cy="4"
176-
r="3"
177-
fill="currentColor"
218+
cx="36"
219+
cy="8"
220+
r="6"
221+
fill="#F40000"
222+
/>
223+
<circle
224+
className="loading-dot loading-dot-4"
225+
cx="50"
226+
cy="8"
227+
r="6"
228+
fill="#FFFFFF"
229+
stroke="#9CA3AF"
230+
strokeWidth="1"
178231
/>
179232
</svg>
180-
<span className="ml-2 text-xs text-muted-foreground/60">{LOADING_MESSAGES[messageIndex]}</span>
233+
<span className="ml-2 text-xs text-muted-foreground">{parseMarkdownLinks(tips[messageIndex])}</span>
181234
</div>
182235
);
183236
};

components/frontend/src/lib/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ type EnvConfig = {
3131
UNLEASH_URL?: string;
3232
UNLEASH_CLIENT_KEY?: string;
3333
UNLEASH_APP_NAME?: string;
34+
35+
// Loading tips (server-side, optional JSON array)
36+
LOADING_TIPS?: string;
3437
};
3538

3639
function getEnv(key: string, defaultValue?: string): string {
@@ -74,6 +77,7 @@ export const env: EnvConfig = {
7477
UNLEASH_URL: getOptionalEnv('UNLEASH_URL'),
7578
UNLEASH_CLIENT_KEY: getOptionalEnv('UNLEASH_CLIENT_KEY'),
7679
UNLEASH_APP_NAME: getOptionalEnv('UNLEASH_APP_NAME') || 'ambient-code-platform',
80+
LOADING_TIPS: getOptionalEnv('LOADING_TIPS'),
7781
};
7882

7983
/**
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Default loading tips shown during AI response generation.
3+
* These are used as fallback when LOADING_TIPS env var is not configured.
4+
* Tips support markdown-style links: [text](url)
5+
*/
6+
export const DEFAULT_LOADING_TIPS = [
7+
"Tip: Clone sessions to quickly duplicate your setup for similar tasks",
8+
"Tip: Export chat transcripts as Markdown or PDF for documentation",
9+
"Tip: Add multiple repositories as context for cross-repo analysis",
10+
"Tip: Stopped sessions can be resumed without losing your progress",
11+
"Tip: Check MCP Servers to see which tools are available in your session",
12+
"Tip: Repository URLs are remembered for quick re-use across sessions",
13+
"Tip: Adjust temperature and max tokens in session settings for different tasks",
14+
"Tip: Connect Google Drive to export chats directly to your Drive",
15+
"Tip: Load custom workflows from your own Git repositories",
16+
"Tip: Use the artifacts panel to browse and download files created by AI",
17+
];
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Config API service
3+
* Handles runtime configuration endpoints
4+
*/
5+
6+
import { apiClient } from './client';
7+
8+
export type LoadingTipsResponse = {
9+
tips: string[];
10+
};
11+
12+
/**
13+
* Get loading tips from runtime configuration
14+
* Falls back to defaults if not configured
15+
*/
16+
export async function getLoadingTips(): Promise<LoadingTipsResponse> {
17+
return apiClient.get<LoadingTipsResponse>('/config/loading-tips');
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* React Query hook for loading tips
3+
*/
4+
5+
import { useQuery } from '@tanstack/react-query';
6+
import { getLoadingTips } from '@/services/api/config';
7+
8+
/**
9+
* Hook to get loading tips from runtime configuration
10+
* Cached indefinitely since tips rarely change
11+
*/
12+
export function useLoadingTips() {
13+
return useQuery({
14+
queryKey: ['config', 'loading-tips'],
15+
queryFn: getLoadingTips,
16+
staleTime: Infinity, // Tips don't change often, cache for session lifetime
17+
gcTime: Infinity, // Keep in cache for entire session, even when unmounted
18+
retry: 1, // Only retry once, fall back to defaults on failure
19+
});
20+
}

0 commit comments

Comments
 (0)