Skip to content

Commit 81e9b13

Browse files
authored
Merge pull request #7 from adao-max/feat/local-chat-history
feat(webuiapps): persist chat history locally
2 parents be0b778 + 49071e3 commit 81e9b13

12 files changed

Lines changed: 701 additions & 20 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ xcuserdata
8888
# Test & Coverage
8989
coverage/
9090
*.tsbuildinfo
91+
test-results/
92+
playwright-report/
93+
blob-report/
9194

9295
# pnpm
9396
.pnpm-store/

CLAUDE.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,51 @@ Detection rules:
120120
- The user is clearly asking questions, chatting, or making fine-grained code modifications (e.g., "change this button color to red")
121121
- The user used the `/vibe` command (already handled by the command mechanism)
122122
- The user requests resuming or re-running from a specific stage (must explicitly use `/vibe {AppName}` or `/vibe {AppName} --from=XX`)
123+
124+
## Testing
125+
126+
### Unit Tests (Vitest)
127+
128+
Run from the webuiapps package:
129+
130+
```bash
131+
cd apps/webuiapps && pnpm test # single run
132+
cd apps/webuiapps && pnpm test:watch # watch mode
133+
```
134+
135+
### E2E Tests (Playwright)
136+
137+
Run from the repo root. The dev server starts automatically.
138+
139+
```bash
140+
pnpm test:e2e # headless, Chromium only
141+
pnpm test:e2e:ui # interactive UI mode
142+
```
143+
144+
- Config: `playwright.config.ts` (root)
145+
- Tests: `e2e/` directory
146+
- The web server (`pnpm dev`) is auto-launched on port 3000 and reused if already running.
147+
- Only Chromium is configured by default; add projects in `playwright.config.ts` for Firefox/WebKit.
148+
- After completing code changes that affect UI or routing, run `pnpm test:e2e` and report pass/fail.
149+
150+
## Task completion quality bar (mandatory)
151+
152+
Before declaring a task complete, agents must satisfy all of the following:
153+
154+
1. **Unit tests must pass** for the affected package(s).
155+
- For `apps/webuiapps`, run the relevant Vitest command(s), for example:
156+
```bash
157+
cd apps/webuiapps && pnpm test
158+
cd apps/webuiapps && pnpm test:coverage
159+
```
160+
2. **Code coverage must be > 90%** for the code touched by the task.
161+
- If current config thresholds are lower, do not treat that as sufficient.
162+
- Add or improve tests until the changed area exceeds 90% coverage, or explicitly report why that is not yet achievable.
163+
3. **E2E coverage must be complete for impacted user flows.**
164+
- Do not stop at smoke tests if the change affects real behavior.
165+
- Cover the primary user path, key state transitions, and at least one meaningful assertion of successful behavior.
166+
- If UI behavior changes, prefer stable selectors (`data-testid`) over fragile class-name/text-only selectors.
167+
4. **Report exact validation commands and results** in the final handoff.
168+
- Include what passed, what failed, and any known gaps.
169+
170+
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.

apps/webuiapps/src/components/AppWindow/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ const AppWindow: React.FC<Props> = ({ win }) => {
106106
return (
107107
<div
108108
className={styles.window}
109+
data-testid={`app-window-${win.appId}`}
109110
style={{
110111
left: win.x,
111112
top: win.y,
@@ -132,6 +133,7 @@ const AppWindow: React.FC<Props> = ({ win }) => {
132133
reportUserOsAction('CLOSE_APP', { app_id: String(win.appId) });
133134
}}
134135
title="Close"
136+
data-testid={`window-close-${win.appId}`}
135137
>
136138
<X size={12} />
137139
</button>

apps/webuiapps/src/components/ChatPanel/index.tsx

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState, useRef, useEffect, useCallback } from 'react';
2-
import { Settings, X } from 'lucide-react';
2+
import { Settings, X, Trash2 } from 'lucide-react';
33
import {
44
chat,
55
loadConfig,
@@ -35,15 +35,15 @@ import {
3535
isImageGenTool,
3636
executeImageGenTool,
3737
} from '@/lib/imageGenTools';
38+
import {
39+
loadChatHistory,
40+
loadChatHistorySync,
41+
saveChatHistory,
42+
clearChatHistory,
43+
type DisplayMessage,
44+
} from '@/lib/chatHistoryStorage';
3845
import styles from './index.module.scss';
3946

40-
interface DisplayMessage {
41-
id: string;
42-
role: 'user' | 'assistant' | 'tool';
43-
content: string;
44-
imageUrl?: string;
45-
}
46-
4747
function buildSystemPrompt(hasImageGen: boolean): string {
4848
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.
4949
@@ -67,8 +67,10 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
6767
onClose,
6868
visible = true,
6969
}) => {
70-
const [messages, setMessages] = useState<DisplayMessage[]>([]);
71-
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
70+
// Init display + LLM history from localStorage cache (sync), then override from file
71+
const [initialCache] = useState(() => loadChatHistorySync());
72+
const [messages, setMessages] = useState<DisplayMessage[]>(initialCache?.messages ?? []);
73+
const [chatHistory, setChatHistory] = useState<ChatMessage[]>(initialCache?.chatHistory ?? []);
7274
const [input, setInput] = useState('');
7375
const [loading, setLoading] = useState(false);
7476
const [showSettings, setShowSettings] = useState(false);
@@ -79,7 +81,37 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
7981
);
8082
const messagesEndRef = useRef<HTMLDivElement>(null);
8183

84+
// Refs for latest state — declared before the debounced save effect that uses them
85+
const messagesRef = useRef(messages);
86+
messagesRef.current = messages;
87+
const chatHistoryRef = useRef(chatHistory);
88+
chatHistoryRef.current = chatHistory;
89+
90+
// Debounced save: persist chat history whenever messages or chatHistory change
91+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
92+
93+
useEffect(() => {
94+
// Skip saving the initial empty state (avoids overwriting persisted data on mount)
95+
if (messages.length === 0 && chatHistory.length === 0) return;
96+
97+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
98+
saveTimerRef.current = setTimeout(() => {
99+
saveChatHistory(messagesRef.current, chatHistoryRef.current);
100+
}, 500);
101+
102+
return () => {
103+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
104+
};
105+
}, [messages, chatHistory]);
106+
82107
useEffect(() => {
108+
// Load from file (async, overrides localStorage cache if available)
109+
loadChatHistory().then((data) => {
110+
if (data) {
111+
setMessages(data.messages);
112+
setChatHistory(data.chatHistory);
113+
}
114+
});
83115
loadConfig().then((fileConfig) => {
84116
if (fileConfig) setConfig(fileConfig);
85117
});
@@ -88,6 +120,12 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
88120
});
89121
}, []);
90122

123+
const handleClearHistory = useCallback(async () => {
124+
setMessages([]);
125+
setChatHistory([]);
126+
await clearChatHistory();
127+
}, []);
128+
91129
useEffect(() => {
92130
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
93131
}, [messages, loading]);
@@ -97,8 +135,6 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
97135
}, []);
98136

99137
// Use refs to keep latest state for user action listener
100-
const chatHistoryRef = useRef(chatHistory);
101-
chatHistoryRef.current = chatHistory;
102138
const configRef = useRef(config);
103139
configRef.current = config;
104140
const imageGenConfigRef = useRef(imageGenConfig);
@@ -452,14 +488,23 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
452488

453489
return (
454490
<>
455-
<div className={styles.panel}>
491+
<div className={styles.panel} data-testid="chat-panel">
456492
<div className={styles.header}>
457493
<span>Chat</span>
458494
<div className={styles.headerActions}>
495+
<button
496+
className={styles.iconBtn}
497+
onClick={handleClearHistory}
498+
title="Clear chat"
499+
data-testid="clear-chat"
500+
>
501+
<Trash2 size={16} />
502+
</button>
459503
<button
460504
className={styles.iconBtn}
461505
onClick={() => setShowSettings(true)}
462506
title="Settings"
507+
data-testid="settings-btn"
463508
>
464509
<Settings size={16} />
465510
</button>
@@ -469,7 +514,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
469514
</div>
470515
</div>
471516

472-
<div className={styles.messages}>
517+
<div className={styles.messages} data-testid="chat-messages">
473518
{messages.length === 0 && (
474519
<div className={styles.emptyState}>
475520
{config?.apiKey ? 'Start a conversation...' : 'Click ⚙ to configure your LLM API key'}
@@ -505,11 +550,13 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
505550
placeholder="Type a message..."
506551
rows={1}
507552
disabled={loading}
553+
data-testid="chat-input"
508554
/>
509555
<button
510556
className={styles.sendBtn}
511557
onClick={handleSend}
512558
disabled={loading || !input.trim()}
559+
data-testid="send-btn"
513560
>
514561
Send
515562
</button>
@@ -577,8 +624,8 @@ const SettingsModal: React.FC<{
577624
};
578625

579626
return (
580-
<div className={styles.overlay}>
581-
<div className={styles.settingsModal}>
627+
<div className={styles.overlay} data-testid="settings-overlay">
628+
<div className={styles.settingsModal} data-testid="settings-modal">
582629
<div className={styles.settingsTitle}>LLM Settings</div>
583630

584631
<div className={styles.field}>

apps/webuiapps/src/components/Shell/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const Shell: React.FC = () => {
105105
return (
106106
<div
107107
className={styles.shell}
108+
data-testid="shell"
108109
style={
109110
activeWallpaper && !showVideo
110111
? {
@@ -119,12 +120,13 @@ const Shell: React.FC = () => {
119120
<video className={styles.videoBg} src={wallpaper} autoPlay loop muted playsInline />
120121
)}
121122
{/* Desktop with app icons */}
122-
<div className={styles.desktop}>
123+
<div className={styles.desktop} data-testid="desktop">
123124
<div className={styles.iconGrid}>
124125
{DESKTOP_APPS.map((app) => (
125126
<button
126127
key={app.appId}
127128
className={styles.appIcon}
129+
data-testid={`app-icon-${app.appId}`}
128130
onDoubleClick={() => {
129131
openWindow(app.appId);
130132
reportUserOsAction('OPEN_APP', { app_id: String(app.appId) });
@@ -155,6 +157,7 @@ const Shell: React.FC = () => {
155157
className={`${styles.liveWallpaperToggle} ${chatOpen ? styles.chatOpen : ''} ${liveWallpaper ? styles.liveOn : styles.liveOff}`}
156158
onClick={() => setLiveWallpaper((prev) => !prev)}
157159
title={liveWallpaper ? 'Live wallpaper: ON' : 'Live wallpaper: OFF'}
160+
data-testid="wallpaper-toggle"
158161
>
159162
{liveWallpaper ? <Video size={16} /> : <VideoOff size={16} />}
160163
</button>
@@ -163,6 +166,7 @@ const Shell: React.FC = () => {
163166
className={`${styles.langToggle} ${chatOpen ? styles.chatOpen : ''}`}
164167
onClick={handleToggleLang}
165168
title={lang === 'en' ? 'Switch to Chinese' : 'Switch to English'}
169+
data-testid="lang-toggle"
166170
>
167171
{lang === 'en' ? 'EN' : 'ZH'}
168172
</button>
@@ -171,6 +175,7 @@ const Shell: React.FC = () => {
171175
className={`${styles.reportToggle} ${chatOpen ? styles.chatOpen : ''} ${reportEnabled ? styles.reportOn : styles.reportOff}`}
172176
onClick={handleToggleReport}
173177
title={reportEnabled ? 'User action reporting: ON' : 'User action reporting: OFF'}
178+
data-testid="report-toggle"
174179
>
175180
<Radio size={16} />
176181
</button>
@@ -179,6 +184,7 @@ const Shell: React.FC = () => {
179184
className={`${styles.chatToggle} ${chatOpen ? styles.chatOpen : ''}`}
180185
onClick={() => setChatOpen(!chatOpen)}
181186
title="Toggle Chat"
187+
data-testid="chat-toggle"
182188
>
183189
<MessageCircle size={20} />
184190
</button>

0 commit comments

Comments
 (0)