Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ xcuserdata
# Test & Coverage
coverage/
*.tsbuildinfo
test-results/
playwright-report/
blob-report/

# pnpm
.pnpm-store/
Expand Down
48 changes: 48 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,51 @@ Detection rules:
- The user is clearly asking questions, chatting, or making fine-grained code modifications (e.g., "change this button color to red")
- The user used the `/vibe` command (already handled by the command mechanism)
- The user requests resuming or re-running from a specific stage (must explicitly use `/vibe {AppName}` or `/vibe {AppName} --from=XX`)

## Testing

### Unit Tests (Vitest)

Run from the webuiapps package:

```bash
cd apps/webuiapps && pnpm test # single run
cd apps/webuiapps && pnpm test:watch # watch mode
```

### E2E Tests (Playwright)

Run from the repo root. The dev server starts automatically.

```bash
pnpm test:e2e # headless, Chromium only
pnpm test:e2e:ui # interactive UI mode
```

- Config: `playwright.config.ts` (root)
- Tests: `e2e/` directory
- The web server (`pnpm dev`) is auto-launched on port 3000 and reused if already running.
- Only Chromium is configured by default; add projects in `playwright.config.ts` for Firefox/WebKit.
- After completing code changes that affect UI or routing, run `pnpm test:e2e` and report pass/fail.

## Task completion quality bar (mandatory)

Before declaring a task complete, agents must satisfy all of the following:

1. **Unit tests must pass** for the affected package(s).
- For `apps/webuiapps`, run the relevant Vitest command(s), for example:
```bash
cd apps/webuiapps && pnpm test
cd apps/webuiapps && pnpm test:coverage
```
2. **Code coverage must be > 90%** for the code touched by the task.
- If current config thresholds are lower, do not treat that as sufficient.
- Add or improve tests until the changed area exceeds 90% coverage, or explicitly report why that is not yet achievable.
3. **E2E coverage must be complete for impacted user flows.**
- Do not stop at smoke tests if the change affects real behavior.
- Cover the primary user path, key state transitions, and at least one meaningful assertion of successful behavior.
- If UI behavior changes, prefer stable selectors (`data-testid`) over fragile class-name/text-only selectors.
4. **Report exact validation commands and results** in the final handoff.
- Include what passed, what failed, and any known gaps.

Minimum expectation: no task is "done" if unit tests are red, coverage on changed code is below 90%, or impacted E2E coverage is missing/incomplete.
2 changes: 2 additions & 0 deletions apps/webuiapps/src/components/AppWindow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const AppWindow: React.FC<Props> = ({ win }) => {
return (
<div
className={styles.window}
data-testid={`app-window-${win.appId}`}
style={{
left: win.x,
top: win.y,
Expand All @@ -132,6 +133,7 @@ const AppWindow: React.FC<Props> = ({ win }) => {
reportUserOsAction('CLOSE_APP', { app_id: String(win.appId) });
}}
title="Close"
data-testid={`window-close-${win.appId}`}
>
<X size={12} />
</button>
Expand Down
79 changes: 63 additions & 16 deletions apps/webuiapps/src/components/ChatPanel/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Settings, X } from 'lucide-react';
import { Settings, X, Trash2 } from 'lucide-react';
import {
chat,
loadConfig,
Expand Down Expand Up @@ -34,15 +34,15 @@ import {
isImageGenTool,
executeImageGenTool,
} from '@/lib/imageGenTools';
import {
loadChatHistory,
loadChatHistorySync,
saveChatHistory,
clearChatHistory,
type DisplayMessage,
} from '@/lib/chatHistoryStorage';
import styles from './index.module.scss';

interface DisplayMessage {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
imageUrl?: string;
}

function buildSystemPrompt(hasImageGen: boolean): string {
return `You are a helpful assistant that can interact with apps on the user's device. Respond in English by default. If the user writes in another language, switch to that language.

Expand All @@ -66,8 +66,10 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
onClose,
visible = true,
}) => {
const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
// Init display + LLM history from localStorage cache (sync), then override from file
const [initialCache] = useState(() => loadChatHistorySync());
const [messages, setMessages] = useState<DisplayMessage[]>(initialCache?.messages ?? []);
const [chatHistory, setChatHistory] = useState<ChatMessage[]>(initialCache?.chatHistory ?? []);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false);
Expand All @@ -76,12 +78,48 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
const [imageGenConfig, setImageGenConfig] = useState<ImageGenConfig | null>(loadImageGenConfig);
const messagesEndRef = useRef<HTMLDivElement>(null);

// Refs for latest state — declared before the debounced save effect that uses them
const messagesRef = useRef(messages);
messagesRef.current = messages;
const chatHistoryRef = useRef(chatHistory);
chatHistoryRef.current = chatHistory;

// Debounced save: persist chat history whenever messages or chatHistory change
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
// Skip saving the initial empty state (avoids overwriting persisted data on mount)
if (messages.length === 0 && chatHistory.length === 0) return;

if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveChatHistory(messagesRef.current, chatHistoryRef.current);
}, 500);

return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [messages, chatHistory]);

useEffect(() => {
// Load from file (async, overrides localStorage cache if available)
loadChatHistory().then((data) => {
if (data) {
setMessages(data.messages);
setChatHistory(data.chatHistory);
}
});
loadConfig().then((fileConfig) => {
if (fileConfig) setConfig(fileConfig);
});
}, []);

const handleClearHistory = useCallback(async () => {
setMessages([]);
setChatHistory([]);
await clearChatHistory();
}, []);

useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, loading]);
Expand All @@ -91,8 +129,6 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
}, []);

// Use refs to keep latest state for user action listener
const chatHistoryRef = useRef(chatHistory);
chatHistoryRef.current = chatHistory;
const configRef = useRef(config);
configRef.current = config;
const imageGenConfigRef = useRef(imageGenConfig);
Expand Down Expand Up @@ -446,14 +482,23 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({

return (
<>
<div className={styles.panel}>
<div className={styles.panel} data-testid="chat-panel">
<div className={styles.header}>
<span>Chat</span>
<div className={styles.headerActions}>
<button
className={styles.iconBtn}
onClick={handleClearHistory}
title="Clear chat"
data-testid="clear-chat"
>
<Trash2 size={16} />
</button>
<button
className={styles.iconBtn}
onClick={() => setShowSettings(true)}
title="Settings"
data-testid="settings-btn"
>
<Settings size={16} />
</button>
Expand All @@ -463,7 +508,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
</div>
</div>

<div className={styles.messages}>
<div className={styles.messages} data-testid="chat-messages">
{messages.length === 0 && (
<div className={styles.emptyState}>
{config?.apiKey ? 'Start a conversation...' : 'Click ⚙ to configure your LLM API key'}
Expand Down Expand Up @@ -499,11 +544,13 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
placeholder="Type a message..."
rows={1}
disabled={loading}
data-testid="chat-input"
/>
<button
className={styles.sendBtn}
onClick={handleSend}
disabled={loading || !input.trim()}
data-testid="send-btn"
>
Send
</button>
Expand Down Expand Up @@ -570,8 +617,8 @@ const SettingsModal: React.FC<{
};

return (
<div className={styles.overlay}>
<div className={styles.settingsModal}>
<div className={styles.overlay} data-testid="settings-overlay">
<div className={styles.settingsModal} data-testid="settings-modal">
<div className={styles.settingsTitle}>LLM Settings</div>

<div className={styles.field}>
Expand Down
8 changes: 7 additions & 1 deletion apps/webuiapps/src/components/Shell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const Shell: React.FC = () => {
return (
<div
className={styles.shell}
data-testid="shell"
style={
activeWallpaper && !showVideo
? {
Expand All @@ -119,12 +120,13 @@ const Shell: React.FC = () => {
<video className={styles.videoBg} src={wallpaper} autoPlay loop muted playsInline />
)}
{/* Desktop with app icons */}
<div className={styles.desktop}>
<div className={styles.desktop} data-testid="desktop">
<div className={styles.iconGrid}>
{DESKTOP_APPS.map((app) => (
<button
key={app.appId}
className={styles.appIcon}
data-testid={`app-icon-${app.appId}`}
onDoubleClick={() => {
openWindow(app.appId);
reportUserOsAction('OPEN_APP', { app_id: String(app.appId) });
Expand Down Expand Up @@ -155,6 +157,7 @@ const Shell: React.FC = () => {
className={`${styles.liveWallpaperToggle} ${chatOpen ? styles.chatOpen : ''} ${liveWallpaper ? styles.liveOn : styles.liveOff}`}
onClick={() => setLiveWallpaper((prev) => !prev)}
title={liveWallpaper ? 'Live wallpaper: ON' : 'Live wallpaper: OFF'}
data-testid="wallpaper-toggle"
>
{liveWallpaper ? <Video size={16} /> : <VideoOff size={16} />}
</button>
Expand All @@ -163,6 +166,7 @@ const Shell: React.FC = () => {
className={`${styles.langToggle} ${chatOpen ? styles.chatOpen : ''}`}
onClick={handleToggleLang}
title={lang === 'en' ? 'Switch to Chinese' : 'Switch to English'}
data-testid="lang-toggle"
>
{lang === 'en' ? 'EN' : 'ZH'}
</button>
Expand All @@ -171,6 +175,7 @@ const Shell: React.FC = () => {
className={`${styles.reportToggle} ${chatOpen ? styles.chatOpen : ''} ${reportEnabled ? styles.reportOn : styles.reportOff}`}
onClick={handleToggleReport}
title={reportEnabled ? 'User action reporting: ON' : 'User action reporting: OFF'}
data-testid="report-toggle"
>
<Radio size={16} />
</button>
Expand All @@ -179,6 +184,7 @@ const Shell: React.FC = () => {
className={`${styles.chatToggle} ${chatOpen ? styles.chatOpen : ''}`}
onClick={() => setChatOpen(!chatOpen)}
title="Toggle Chat"
data-testid="chat-toggle"
>
<MessageCircle size={20} />
</button>
Expand Down
Loading
Loading