feat: deliverables 3-6 — cold start, simple mode UI, bridge tests, docs
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Deliverable 3 — Cold Start Flow: - New: use-cold-start.ts — cold start detection + greeting management - Default Chinese greeting for hospital admin users - Phase tracking: idle → greeting_sent → waiting_response → completed Deliverable 4 — Simple Mode UI: - New: uiModeStore.ts — 'simple'|'professional' mode with localStorage - New: SimpleTopBar.tsx — minimal top bar with mode toggle - Modified: App.tsx — dual layout rendering based on UI mode - Modified: ChatArea.tsx — compact prop hides advanced controls - Default: 'simple' mode for zero-barrier first experience Deliverable 5 — Tauri Bridge Integration Tests: - New: tauri-bridge.integration.test.ts — 14 test cases - Covers: cold start, chat flow, persistence, memory, butler, UI mode, e2e - 14/14 passing Deliverable 6 — Release Documentation: - New: installation-guide.md — user-facing install guide (Chinese, no jargon) - New: hospital-deployment.md — IT admin deployment guide (Docker, GPO, SCCM)
This commit is contained in:
@@ -27,6 +27,8 @@ import { useToast } from './components/ui/Toast';
|
||||
import type { Clone } from './store/agentStore';
|
||||
import { createLogger } from './lib/logger';
|
||||
import { startOfflineMonitor } from './store/offlineStore';
|
||||
import { useUIModeStore } from './store/uiModeStore';
|
||||
import { SimpleTopBar } from './components/SimpleTopBar';
|
||||
|
||||
const log = createLogger('App');
|
||||
|
||||
@@ -443,6 +445,33 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const uiMode = useUIModeStore((s) => s.mode);
|
||||
|
||||
// Simple mode: single-column layout with top bar only
|
||||
if (uiMode === 'simple') {
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden text-gray-800 text-sm bg-white dark:bg-gray-950">
|
||||
<SimpleTopBar onToggleMode={() => useUIModeStore.getState().setMode('professional')} />
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChatArea compact />
|
||||
</div>
|
||||
|
||||
{/* Hand Approval Modal (global) */}
|
||||
<HandApprovalModal
|
||||
handRun={pendingApprovalRun}
|
||||
isOpen={showApprovalModal}
|
||||
onApprove={handleApproveHand}
|
||||
onReject={handleRejectHand}
|
||||
onClose={handleCloseApprovalModal}
|
||||
/>
|
||||
|
||||
{/* Proposal Notifications Handler */}
|
||||
<ProposalNotificationHandler />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Professional mode: three-column layout (default)
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden text-gray-800 text-sm bg-white dark:bg-gray-950">
|
||||
{/* 左侧边栏 */}
|
||||
|
||||
@@ -49,7 +49,7 @@ const DEFAULT_MESSAGE_HEIGHTS: Record<string, number> = {
|
||||
// Threshold for enabling virtualization (messages count)
|
||||
const VIRTUALIZATION_THRESHOLD = 100;
|
||||
|
||||
export function ChatArea() {
|
||||
export function ChatArea({ compact }: { compact?: boolean }) {
|
||||
const {
|
||||
messages, isStreaming, isLoading,
|
||||
sendMessage: sendToGateway, initStreamListener,
|
||||
@@ -343,7 +343,7 @@ export function ChatArea() {
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Token usage counter — DeerFlow-style plain text */}
|
||||
{(totalInputTokens + totalOutputTokens) > 0 && (() => {
|
||||
{!compact && (totalInputTokens + totalOutputTokens) > 0 && (() => {
|
||||
const total = totalInputTokens + totalOutputTokens;
|
||||
const display = total >= 1000 ? `${(total / 1000).toFixed(1)}K` : String(total);
|
||||
return (
|
||||
@@ -353,7 +353,7 @@ export function ChatArea() {
|
||||
);
|
||||
})()}
|
||||
<OfflineIndicator compact />
|
||||
{messages.length > 0 && (
|
||||
{!compact && messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -561,11 +561,12 @@ export function ChatArea() {
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</Button>
|
||||
<ChatMode
|
||||
{!compact && <ChatMode
|
||||
value={chatMode}
|
||||
onChange={setChatMode}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector
|
||||
|
||||
54
desktop/src/components/SimpleTopBar.tsx
Normal file
54
desktop/src/components/SimpleTopBar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* SimpleTopBar - Minimal top bar for simple UI mode
|
||||
*
|
||||
* Shows only the ZCLAW logo and a mode-toggle button.
|
||||
* Designed for the streamlined "simple" experience.
|
||||
*/
|
||||
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
// === Types ===
|
||||
|
||||
interface SimpleTopBarProps {
|
||||
onToggleMode: () => void;
|
||||
}
|
||||
|
||||
// === Component ===
|
||||
|
||||
export function SimpleTopBar({ onToggleMode }: SimpleTopBarProps) {
|
||||
return (
|
||||
<header className="h-10 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 flex items-center px-4 flex-shrink-0 select-none">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-lg bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
|
||||
ZCLAW
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Mode toggle button */}
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onToggleMode}
|
||||
className="
|
||||
flex items-center gap-1.5 px-2.5 py-1 rounded-md
|
||||
text-xs text-gray-500 dark:text-gray-400
|
||||
hover:text-gray-900 dark:hover:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-800
|
||||
transition-colors duration-150
|
||||
"
|
||||
whileHover={{ scale: 1.04 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
title="切换到专业模式"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
<span>更多功能</span>
|
||||
</motion.button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimpleTopBar;
|
||||
206
desktop/src/lib/use-cold-start.ts
Normal file
206
desktop/src/lib/use-cold-start.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* useColdStart - Cold start state management hook
|
||||
*
|
||||
* Detects first-time users and manages the cold start greeting flow.
|
||||
* Reuses the onboarding completion key to determine if user is new.
|
||||
*
|
||||
* Flow: idle -> greeting_sent -> waiting_response -> completed
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger('useColdStart');
|
||||
|
||||
// Reuse the same key from use-onboarding.ts
|
||||
const ONBOARDING_COMPLETED_KEY = 'zclaw-onboarding-completed';
|
||||
|
||||
// Cold start state persisted to localStorage
|
||||
const COLD_START_STATE_KEY = 'zclaw-cold-start-state';
|
||||
|
||||
// Re-export UserProfile for consumers that need it
|
||||
export type { UserProfile } from './use-onboarding';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type ColdStartPhase = 'idle' | 'greeting_sent' | 'waiting_response' | 'completed';
|
||||
|
||||
export interface ColdStartState {
|
||||
isColdStart: boolean;
|
||||
phase: ColdStartPhase;
|
||||
greetingSent: boolean;
|
||||
markGreetingSent: () => void;
|
||||
markWaitingResponse: () => void;
|
||||
markCompleted: () => void;
|
||||
getGreetingMessage: (agentName?: string, agentEmoji?: string) => string;
|
||||
}
|
||||
|
||||
// === Default Greeting ===
|
||||
|
||||
const DEFAULT_GREETING_BODY =
|
||||
'我可以帮您处理数据报告、会议纪要、政策合规检查等日常工作。\n\n请问您是哪个科室的?主要负责哪方面的工作?';
|
||||
|
||||
const FALLBACK_GREETING =
|
||||
'您好!我是您的工作助手。我可以帮您处理数据报告、会议纪要、政策合规检查等工作。请问您是哪个科室的?';
|
||||
|
||||
// === Persistence Helpers ===
|
||||
|
||||
interface PersistedColdStart {
|
||||
phase: ColdStartPhase;
|
||||
}
|
||||
|
||||
function loadPersistedPhase(): ColdStartPhase {
|
||||
try {
|
||||
const raw = localStorage.getItem(COLD_START_STATE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as PersistedColdStart;
|
||||
if (parsed && typeof parsed.phase === 'string') {
|
||||
return parsed.phase;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to read cold start state:', err);
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function persistPhase(phase: ColdStartPhase): void {
|
||||
try {
|
||||
const data: PersistedColdStart = { phase };
|
||||
localStorage.setItem(COLD_START_STATE_KEY, JSON.stringify(data));
|
||||
} catch (err) {
|
||||
log.warn('Failed to persist cold start state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// === Greeting Builder ===
|
||||
|
||||
function buildGreeting(agentName?: string, agentEmoji?: string): string {
|
||||
if (!agentName) {
|
||||
return FALLBACK_GREETING;
|
||||
}
|
||||
|
||||
const emoji = agentEmoji ? ` ${agentEmoji}` : '';
|
||||
return `您好!我是${agentName}${emoji}\n\n${DEFAULT_GREETING_BODY}`;
|
||||
}
|
||||
|
||||
// === Hook ===
|
||||
|
||||
/**
|
||||
* Hook to manage cold start state for first-time users.
|
||||
*
|
||||
* A user is considered "cold start" when they have not completed onboarding
|
||||
* AND have not yet gone through the greeting flow.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { isColdStart, phase, markGreetingSent, getGreetingMessage } = useColdStart();
|
||||
*
|
||||
* if (isColdStart && phase === 'idle') {
|
||||
* const msg = getGreetingMessage(agent.name, agent.emoji);
|
||||
* sendMessage(msg);
|
||||
* markGreetingSent();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useColdStart(): ColdStartState {
|
||||
const [phase, setPhase] = useState<ColdStartPhase>(loadPersistedPhase);
|
||||
const [isColdStart, setIsColdStart] = useState(false);
|
||||
|
||||
// Determine cold start status on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const onboardingCompleted = localStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||
const isNewUser = onboardingCompleted !== 'true';
|
||||
|
||||
if (isNewUser && phase !== 'completed') {
|
||||
setIsColdStart(true);
|
||||
} else {
|
||||
setIsColdStart(false);
|
||||
// If onboarding is completed but phase is not completed,
|
||||
// force phase to completed to avoid stuck states
|
||||
if (phase !== 'completed') {
|
||||
setPhase('completed');
|
||||
persistPhase('completed');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to check cold start status:', err);
|
||||
setIsColdStart(false);
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
const markGreetingSent = useCallback(() => {
|
||||
const nextPhase: ColdStartPhase = 'greeting_sent';
|
||||
setPhase(nextPhase);
|
||||
persistPhase(nextPhase);
|
||||
log.debug('Cold start: greeting sent');
|
||||
}, []);
|
||||
|
||||
const markWaitingResponse = useCallback(() => {
|
||||
const nextPhase: ColdStartPhase = 'waiting_response';
|
||||
setPhase(nextPhase);
|
||||
persistPhase(nextPhase);
|
||||
log.debug('Cold start: waiting for user response');
|
||||
}, []);
|
||||
|
||||
const markCompleted = useCallback(() => {
|
||||
const nextPhase: ColdStartPhase = 'completed';
|
||||
setPhase(nextPhase);
|
||||
persistPhase(nextPhase);
|
||||
setIsColdStart(false);
|
||||
log.debug('Cold start: completed');
|
||||
}, []);
|
||||
|
||||
const getGreetingMessage = useCallback(
|
||||
(agentName?: string, agentEmoji?: string): string => {
|
||||
return buildGreeting(agentName, agentEmoji);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
isColdStart,
|
||||
phase,
|
||||
greetingSent: phase === 'greeting_sent' || phase === 'waiting_response' || phase === 'completed',
|
||||
markGreetingSent,
|
||||
markWaitingResponse,
|
||||
markCompleted,
|
||||
getGreetingMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// === Non-hook Accessor ===
|
||||
|
||||
/**
|
||||
* Get cold start state without React hook (for use outside components).
|
||||
*/
|
||||
export function getColdStartState(): { isColdStart: boolean; phase: ColdStartPhase } {
|
||||
try {
|
||||
const onboardingCompleted = localStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||
const isNewUser = onboardingCompleted !== 'true';
|
||||
const phase = loadPersistedPhase();
|
||||
|
||||
return {
|
||||
isColdStart: isNewUser && phase !== 'completed',
|
||||
phase,
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn('Failed to get cold start state:', err);
|
||||
return { isColdStart: false, phase: 'completed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset cold start state (for testing or debugging).
|
||||
*/
|
||||
export function resetColdStartState(): void {
|
||||
try {
|
||||
localStorage.removeItem(COLD_START_STATE_KEY);
|
||||
log.debug('Cold start state reset');
|
||||
} catch (err) {
|
||||
log.warn('Failed to reset cold start state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export default useColdStart;
|
||||
91
desktop/src/store/uiModeStore.ts
Normal file
91
desktop/src/store/uiModeStore.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* uiModeStore.ts - UI Mode Management Store
|
||||
*
|
||||
* Manages the toggle between simple mode and professional mode.
|
||||
* Persists preference to localStorage.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
const log = createLogger('uiModeStore');
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type UIMode = 'simple' | 'professional';
|
||||
|
||||
export interface UIModeState {
|
||||
mode: UIMode;
|
||||
setMode: (mode: UIMode) => void;
|
||||
toggleMode: () => void;
|
||||
}
|
||||
|
||||
// === Constants ===
|
||||
|
||||
const UI_MODE_STORAGE_KEY = 'zclaw-ui-mode';
|
||||
const DEFAULT_MODE: UIMode = 'simple';
|
||||
|
||||
// === Persistence Helpers ===
|
||||
|
||||
function loadStoredMode(): UIMode {
|
||||
try {
|
||||
const stored = localStorage.getItem(UI_MODE_STORAGE_KEY);
|
||||
if (stored === 'simple' || stored === 'professional') {
|
||||
return stored;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to read UI mode from localStorage:', err);
|
||||
}
|
||||
return DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function persistMode(mode: UIMode): void {
|
||||
try {
|
||||
localStorage.setItem(UI_MODE_STORAGE_KEY, mode);
|
||||
} catch (err) {
|
||||
log.warn('Failed to persist UI mode:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// === Store ===
|
||||
|
||||
export const useUIModeStore = create<UIModeState>((set, get) => ({
|
||||
mode: loadStoredMode(),
|
||||
|
||||
setMode: (mode: UIMode) => {
|
||||
const current = get().mode;
|
||||
if (current === mode) return;
|
||||
|
||||
persistMode(mode);
|
||||
set({ mode });
|
||||
log.debug('UI mode changed:', mode);
|
||||
},
|
||||
|
||||
toggleMode: () => {
|
||||
const current = get().mode;
|
||||
const next: UIMode = current === 'simple' ? 'professional' : 'simple';
|
||||
|
||||
persistMode(next);
|
||||
set({ mode: next });
|
||||
log.debug('UI mode toggled:', current, '->', next);
|
||||
},
|
||||
}));
|
||||
|
||||
// === Non-hook Accessors ===
|
||||
|
||||
/**
|
||||
* Get current UI mode without React hook.
|
||||
*/
|
||||
export const getUIMode = (): UIMode => useUIModeStore.getState().mode;
|
||||
|
||||
/**
|
||||
* Check if current mode is simple.
|
||||
*/
|
||||
export const isSimpleMode = (): boolean => useUIModeStore.getState().mode === 'simple';
|
||||
|
||||
/**
|
||||
* Check if current mode is professional.
|
||||
*/
|
||||
export const isProfessionalMode = (): boolean => useUIModeStore.getState().mode === 'professional';
|
||||
|
||||
export default useUIModeStore;
|
||||
Reference in New Issue
Block a user