-
+
{projectOverview.projectName}
diff --git a/ccw/frontend/src/stores/appStore.ts b/ccw/frontend/src/stores/appStore.ts
index fd885866..c77b05cc 100644
--- a/ccw/frontend/src/stores/appStore.ts
+++ b/ccw/frontend/src/stores/appStore.ts
@@ -5,11 +5,12 @@
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
-import type { AppStore, Theme, ColorScheme, GradientLevel, Locale, ViewMode, SessionFilter, LiteTaskType, DashboardLayouts, WidgetConfig } from '../types/store';
+import type { AppStore, Theme, ColorScheme, GradientLevel, Locale, ViewMode, SessionFilter, LiteTaskType, DashboardLayouts, WidgetConfig, MotionPreference, StyleTier, ThemeSlot, ThemeSlotId, BackgroundConfig, BackgroundEffects, BackgroundMode, UnsplashAttribution } from '../types/store';
import { DEFAULT_DASHBOARD_LAYOUT } from '../components/dashboard/defaultLayouts';
import { getInitialLocale, updateIntl } from '../lib/i18n';
-import { getThemeId } from '../lib/theme';
-import { generateThemeFromHue } from '../lib/colorGenerator';
+import { getThemeId, DEFAULT_SLOT, THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '../lib/theme';
+import { generateThemeFromHue, applyStyleTier } from '../lib/colorGenerator';
+import { resolveMotionPreference, checkThemeContrast } from '../lib/accessibility';
// Helper to resolve system theme
const getSystemTheme = (): 'light' | 'dark' => {
@@ -25,6 +26,12 @@ const resolveTheme = (theme: Theme): 'light' | 'dark' => {
return theme;
};
+/** Get the style tier from the active slot */
+const getActiveStyleTier = (themeSlots: ThemeSlot[], activeSlotId: ThemeSlotId): StyleTier => {
+ const slot = themeSlots.find(s => s.id === activeSlotId);
+ return slot?.styleTier ?? 'standard';
+};
+
/**
* DOM Theme Application Helper
*
@@ -44,7 +51,9 @@ const applyThemeToDocument = (
customHue: number | null,
gradientLevel: GradientLevel = 'standard',
enableHoverGlow: boolean = true,
- enableBackgroundAnimation: boolean = false
+ enableBackgroundAnimation: boolean = false,
+ motionPreference: MotionPreference = 'system',
+ styleTier: StyleTier = 'standard'
): void => {
if (typeof document === 'undefined') return;
@@ -78,11 +87,29 @@ const applyThemeToDocument = (
// Apply custom theme or preset theme
if (customHue !== null) {
- const cssVars = generateThemeFromHue(customHue, resolvedTheme);
+ let cssVars = generateThemeFromHue(customHue, resolvedTheme);
+ // Apply style tier post-processing
+ if (styleTier !== 'standard') {
+ cssVars = applyStyleTier(cssVars, styleTier, resolvedTheme);
+ }
Object.entries(cssVars).forEach(([varName, varValue]) => {
document.documentElement.style.setProperty(varName, varValue);
});
document.documentElement.setAttribute('data-theme', `custom-${resolvedTheme}`);
+
+ // Contrast validation for non-standard tiers
+ if (styleTier !== 'standard') {
+ const contrastResults = checkThemeContrast(cssVars);
+ const failures = contrastResults.filter(r => !r.passed);
+ if (failures.length > 0) {
+ console.warn(
+ '[Theme] Style tier "%s" caused %d WCAG AA contrast failures:',
+ styleTier,
+ failures.length,
+ failures.map(f => `${f.fgVar}/${f.bgVar}: ${f.ratio}:1 (min ${f.required}:1)`)
+ );
+ }
+ }
} else {
// Clear custom CSS variables
customVars.forEach(varName => {
@@ -91,6 +118,35 @@ const applyThemeToDocument = (
// Apply preset theme
const themeId = getThemeId(colorScheme, resolvedTheme);
document.documentElement.setAttribute('data-theme', themeId);
+
+ // Apply style tier to preset theme (if not standard)
+ if (styleTier !== 'standard') {
+ const computed = getComputedStyle(document.documentElement);
+ const presetVars: Record = {};
+ for (const varName of customVars) {
+ const value = computed.getPropertyValue(varName).trim();
+ if (value) {
+ presetVars[varName] = value;
+ }
+ }
+ const tieredVars = applyStyleTier(presetVars, styleTier, resolvedTheme);
+ Object.entries(tieredVars).forEach(([varName, varValue]) => {
+ document.documentElement.style.setProperty(varName, varValue);
+ });
+
+ // Contrast validation for preset themes with non-standard tiers
+ const contrastResults = checkThemeContrast(tieredVars);
+ const failures = contrastResults.filter(r => !r.passed);
+ if (failures.length > 0) {
+ console.warn(
+ '[Theme] Style tier "%s" on preset "%s" caused %d WCAG AA contrast failures:',
+ styleTier,
+ colorScheme,
+ failures.length,
+ failures.map(f => `${f.fgVar}/${f.bgVar}: ${f.ratio}:1 (min ${f.required}:1)`)
+ );
+ }
+ }
}
// Set color scheme attribute
@@ -100,10 +156,19 @@ const applyThemeToDocument = (
document.documentElement.setAttribute('data-gradient', gradientLevel);
document.documentElement.setAttribute('data-hover-glow', String(enableHoverGlow));
document.documentElement.setAttribute('data-bg-animation', String(enableBackgroundAnimation));
+
+ // Apply reduced motion preference
+ const reducedMotion = resolveMotionPreference(motionPreference);
+ document.documentElement.setAttribute('data-reduced-motion', String(reducedMotion));
+
+ // Set style tier data attribute
+ document.documentElement.setAttribute('data-style-tier', styleTier);
};
// Use View Transition API for smooth transitions (progressive enhancement)
- if (typeof document !== 'undefined' && 'startViewTransition' in document) {
+ // Skip view transition when reduced motion is active
+ const reducedMotion = resolveMotionPreference(motionPreference);
+ if (!reducedMotion && typeof document !== 'undefined' && 'startViewTransition' in document) {
(document as unknown as { startViewTransition: (callback: () => void) => void }).startViewTransition(performThemeUpdate);
} else {
// Fallback: apply immediately without transition
@@ -111,6 +176,23 @@ const applyThemeToDocument = (
}
};
+/**
+ * Apply background configuration to document data attributes.
+ * Sets data-bg-* attributes on that CSS rules respond to.
+ */
+const applyBackgroundToDocument = (config: BackgroundConfig): void => {
+ if (typeof document === 'undefined') return;
+
+ const el = document.documentElement;
+ el.setAttribute('data-bg-mode', config.mode);
+ el.setAttribute('data-bg-blur', String(config.effects.blur));
+ el.setAttribute('data-bg-darken', String(config.effects.darkenOpacity));
+ el.setAttribute('data-bg-saturation', String(config.effects.saturation));
+ el.setAttribute('data-bg-frosted', String(config.effects.enableFrostedGlass));
+ el.setAttribute('data-bg-grain', String(config.effects.enableGrain));
+ el.setAttribute('data-bg-vignette', String(config.effects.enableVignette));
+};
+
// Initial state
const initialState = {
// Theme
@@ -125,6 +207,9 @@ const initialState = {
enableHoverGlow: true,
enableBackgroundAnimation: false,
+ // Motion preference
+ motionPreference: 'system' as MotionPreference,
+
// Locale
locale: getInitialLocale() as Locale,
@@ -146,6 +231,11 @@ const initialState = {
// Dashboard layout
dashboardLayout: null,
+
+ // Theme slots
+ themeSlots: [DEFAULT_SLOT] as ThemeSlot[],
+ activeSlotId: 'default' as ThemeSlotId,
+ deletedSlotBuffer: null as ThemeSlot | null,
};
export const useAppStore = create()(
@@ -161,31 +251,60 @@ export const useAppStore = create()(
set({ theme, resolvedTheme: resolved }, false, 'setTheme');
// Apply theme using helper (encapsulates DOM manipulation)
- const { colorScheme, customHue, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
- applyThemeToDocument(resolved, colorScheme, customHue, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
+ const state = get();
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
+ applyThemeToDocument(resolved, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
setColorScheme: (colorScheme: ColorScheme) => {
- set({ colorScheme, customHue: null, isCustomTheme: false }, false, 'setColorScheme');
+ set((state) => ({
+ colorScheme,
+ customHue: null,
+ isCustomTheme: false,
+ themeSlots: state.themeSlots.map(slot =>
+ slot.id === state.activeSlotId
+ ? { ...slot, colorScheme, customHue: null, isCustomTheme: false }
+ : slot
+ ),
+ }), false, 'setColorScheme');
// Apply color scheme using helper (encapsulates DOM manipulation)
- const { resolvedTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
- applyThemeToDocument(resolvedTheme, colorScheme, null, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
+ const state = get();
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
+ applyThemeToDocument(state.resolvedTheme, colorScheme, null, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
setCustomHue: (hue: number | null) => {
if (hue === null) {
// Reset to preset theme
- const { colorScheme, resolvedTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
- set({ customHue: null, isCustomTheme: false }, false, 'setCustomHue');
- applyThemeToDocument(resolvedTheme, colorScheme, null, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
+ const state = get();
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
+ set((s) => ({
+ customHue: null,
+ isCustomTheme: false,
+ themeSlots: s.themeSlots.map(slot =>
+ slot.id === s.activeSlotId
+ ? { ...slot, customHue: null, isCustomTheme: false }
+ : slot
+ ),
+ }), false, 'setCustomHue');
+ applyThemeToDocument(state.resolvedTheme, state.colorScheme, null, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
return;
}
// Apply custom hue
- set({ customHue: hue, isCustomTheme: true }, false, 'setCustomHue');
- const { resolvedTheme, colorScheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
- applyThemeToDocument(resolvedTheme, colorScheme, hue, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
+ set((state) => ({
+ customHue: hue,
+ isCustomTheme: true,
+ themeSlots: state.themeSlots.map(slot =>
+ slot.id === state.activeSlotId
+ ? { ...slot, customHue: hue, isCustomTheme: true }
+ : slot
+ ),
+ }), false, 'setCustomHue');
+ const state = get();
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
+ applyThemeToDocument(state.resolvedTheme, state.colorScheme, hue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
toggleTheme: () => {
@@ -197,21 +316,64 @@ export const useAppStore = create()(
// ========== Gradient Settings Actions ==========
setGradientLevel: (level: GradientLevel) => {
- set({ gradientLevel: level }, false, 'setGradientLevel');
- const { resolvedTheme, colorScheme, customHue, enableHoverGlow, enableBackgroundAnimation } = get();
- applyThemeToDocument(resolvedTheme, colorScheme, customHue, level, enableHoverGlow, enableBackgroundAnimation);
+ set((state) => ({
+ gradientLevel: level,
+ themeSlots: state.themeSlots.map(slot =>
+ slot.id === state.activeSlotId
+ ? { ...slot, gradientLevel: level }
+ : slot
+ ),
+ }), false, 'setGradientLevel');
+ const state = get();
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
+ applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, level, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
setEnableHoverGlow: (enabled: boolean) => {
- set({ enableHoverGlow: enabled }, false, 'setEnableHoverGlow');
- const { resolvedTheme, colorScheme, customHue, gradientLevel, enableBackgroundAnimation } = get();
- applyThemeToDocument(resolvedTheme, colorScheme, customHue, gradientLevel, enabled, enableBackgroundAnimation);
+ set((state) => ({
+ enableHoverGlow: enabled,
+ themeSlots: state.themeSlots.map(slot =>
+ slot.id === state.activeSlotId
+ ? { ...slot, enableHoverGlow: enabled }
+ : slot
+ ),
+ }), false, 'setEnableHoverGlow');
+ const state = get();
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
+ applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, enabled, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
setEnableBackgroundAnimation: (enabled: boolean) => {
- set({ enableBackgroundAnimation: enabled }, false, 'setEnableBackgroundAnimation');
- const { resolvedTheme, colorScheme, customHue, gradientLevel, enableHoverGlow } = get();
- applyThemeToDocument(resolvedTheme, colorScheme, customHue, gradientLevel, enableHoverGlow, enabled);
+ set((state) => ({
+ enableBackgroundAnimation: enabled,
+ themeSlots: state.themeSlots.map(slot =>
+ slot.id === state.activeSlotId
+ ? { ...slot, enableBackgroundAnimation: enabled }
+ : slot
+ ),
+ }), false, 'setEnableBackgroundAnimation');
+ const state = get();
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
+ applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, enabled, state.motionPreference, styleTier);
+ },
+
+ setMotionPreference: (pref: MotionPreference) => {
+ set({ motionPreference: pref }, false, 'setMotionPreference');
+ const state = get();
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
+ applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, pref, styleTier);
+ },
+
+ setStyleTier: (tier: StyleTier) => {
+ set((state) => ({
+ themeSlots: state.themeSlots.map(slot =>
+ slot.id === state.activeSlotId
+ ? { ...slot, styleTier: tier }
+ : slot
+ ),
+ }), false, 'setStyleTier');
+ const state = get();
+ applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, tier);
},
// ========== Locale Actions ==========
@@ -302,10 +464,216 @@ export const useAppStore = create()(
resetDashboardLayout: () => {
set({ dashboardLayout: DEFAULT_DASHBOARD_LAYOUT }, false, 'resetDashboardLayout');
},
+
+ // ========== Theme Slot Actions ==========
+
+ setActiveSlot: (slotId: ThemeSlotId) => {
+ const { themeSlots, motionPreference } = get();
+ const slot = themeSlots.find(s => s.id === slotId);
+ if (!slot) return;
+
+ const resolved = resolveTheme(get().theme);
+ set({
+ activeSlotId: slotId,
+ colorScheme: slot.colorScheme,
+ customHue: slot.customHue,
+ isCustomTheme: slot.isCustomTheme,
+ gradientLevel: slot.gradientLevel,
+ enableHoverGlow: slot.enableHoverGlow,
+ enableBackgroundAnimation: slot.enableBackgroundAnimation,
+ }, false, 'setActiveSlot');
+
+ applyThemeToDocument(
+ resolved,
+ slot.colorScheme,
+ slot.customHue,
+ slot.gradientLevel,
+ slot.enableHoverGlow,
+ slot.enableBackgroundAnimation,
+ motionPreference,
+ slot.styleTier
+ );
+ applyBackgroundToDocument(slot.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
+ },
+
+ copySlot: () => {
+ const state = get();
+ if (state.themeSlots.length >= THEME_SLOT_LIMIT) return;
+
+ // Determine next available slot id
+ const usedIds = new Set(state.themeSlots.map(s => s.id));
+ const candidateIds: ThemeSlotId[] = ['custom-1', 'custom-2'];
+ const nextId = candidateIds.find(id => !usedIds.has(id));
+ if (!nextId) return;
+
+ const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
+ if (!activeSlot) return;
+
+ const newSlot: ThemeSlot = {
+ id: nextId,
+ name: `Copy of ${activeSlot.name}`,
+ colorScheme: state.colorScheme,
+ customHue: state.customHue,
+ isCustomTheme: state.isCustomTheme,
+ gradientLevel: state.gradientLevel,
+ enableHoverGlow: state.enableHoverGlow,
+ enableBackgroundAnimation: state.enableBackgroundAnimation,
+ styleTier: activeSlot.styleTier,
+ isDefault: false,
+ backgroundConfig: activeSlot.backgroundConfig,
+ };
+
+ set({
+ themeSlots: [...state.themeSlots, newSlot],
+ activeSlotId: nextId,
+ }, false, 'copySlot');
+ },
+
+ renameSlot: (slotId: ThemeSlotId, name: string) => {
+ set((state) => ({
+ themeSlots: state.themeSlots.map(slot =>
+ slot.id === slotId ? { ...slot, name } : slot
+ ),
+ }), false, 'renameSlot');
+ },
+
+ deleteSlot: (slotId: ThemeSlotId) => {
+ const state = get();
+ const slot = state.themeSlots.find(s => s.id === slotId);
+ if (!slot || slot.isDefault) return;
+
+ set({
+ themeSlots: state.themeSlots.filter(s => s.id !== slotId),
+ deletedSlotBuffer: slot,
+ activeSlotId: 'default',
+ }, false, 'deleteSlot');
+
+ // Load default slot values into active state
+ const defaultSlot = state.themeSlots.find(s => s.id === 'default');
+ if (defaultSlot) {
+ const resolved = resolveTheme(state.theme);
+ set({
+ colorScheme: defaultSlot.colorScheme,
+ customHue: defaultSlot.customHue,
+ isCustomTheme: defaultSlot.isCustomTheme,
+ gradientLevel: defaultSlot.gradientLevel,
+ enableHoverGlow: defaultSlot.enableHoverGlow,
+ enableBackgroundAnimation: defaultSlot.enableBackgroundAnimation,
+ }, false, 'deleteSlot/applyDefault');
+
+ applyThemeToDocument(
+ resolved,
+ defaultSlot.colorScheme,
+ defaultSlot.customHue,
+ defaultSlot.gradientLevel,
+ defaultSlot.enableHoverGlow,
+ defaultSlot.enableBackgroundAnimation,
+ state.motionPreference,
+ defaultSlot.styleTier
+ );
+ applyBackgroundToDocument(defaultSlot.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
+ }
+
+ // Clear buffer after 10 seconds
+ setTimeout(() => {
+ const current = useAppStore.getState();
+ if (current.deletedSlotBuffer?.id === slotId) {
+ useAppStore.setState({ deletedSlotBuffer: null }, false);
+ }
+ }, 10000);
+ },
+
+ undoDeleteSlot: () => {
+ const state = get();
+ const restored = state.deletedSlotBuffer;
+ if (!restored) return;
+ if (state.themeSlots.length >= THEME_SLOT_LIMIT) return;
+
+ set({
+ themeSlots: [...state.themeSlots, restored],
+ deletedSlotBuffer: null,
+ activeSlotId: restored.id,
+ }, false, 'undoDeleteSlot');
+
+ // Apply restored slot values
+ const resolved = resolveTheme(state.theme);
+ set({
+ colorScheme: restored.colorScheme,
+ customHue: restored.customHue,
+ isCustomTheme: restored.isCustomTheme,
+ gradientLevel: restored.gradientLevel,
+ enableHoverGlow: restored.enableHoverGlow,
+ enableBackgroundAnimation: restored.enableBackgroundAnimation,
+ }, false, 'undoDeleteSlot/apply');
+
+ applyThemeToDocument(
+ resolved,
+ restored.colorScheme,
+ restored.customHue,
+ restored.gradientLevel,
+ restored.enableHoverGlow,
+ restored.enableBackgroundAnimation,
+ state.motionPreference,
+ restored.styleTier
+ );
+ applyBackgroundToDocument(restored.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
+ },
+
+ // ========== Background Actions ==========
+
+ setBackgroundConfig: (config: BackgroundConfig) => {
+ set((state) => ({
+ themeSlots: state.themeSlots.map(slot =>
+ slot.id === state.activeSlotId
+ ? { ...slot, backgroundConfig: config }
+ : slot
+ ),
+ }), false, 'setBackgroundConfig');
+ applyBackgroundToDocument(config);
+ },
+
+ updateBackgroundEffect: (key: K, value: BackgroundEffects[K]) => {
+ const state = get();
+ const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
+ const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
+ const updated: BackgroundConfig = {
+ ...current,
+ effects: { ...current.effects, [key]: value },
+ };
+ get().setBackgroundConfig(updated);
+ },
+
+ setBackgroundMode: (mode: BackgroundMode) => {
+ const state = get();
+ const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
+ const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
+ const updated: BackgroundConfig = { ...current, mode };
+ get().setBackgroundConfig(updated);
+ },
+
+ setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => {
+ const state = get();
+ const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
+ const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
+ const updated: BackgroundConfig = {
+ ...current,
+ imageUrl: url,
+ attribution,
+ };
+ // Auto-switch mode if currently gradient-only and setting an image
+ if (url && current.mode === 'gradient-only') {
+ updated.mode = 'image-gradient';
+ }
+ // Auto-switch to gradient-only if removing image
+ if (!url && current.mode !== 'gradient-only') {
+ updated.mode = 'gradient-only';
+ }
+ get().setBackgroundConfig(updated);
+ },
}),
{
name: 'ccw-app-store',
- // Only persist theme and locale preferences
+ // Only persist theme, locale, and slot preferences
partialize: (state) => ({
theme: state.theme,
colorScheme: state.colorScheme,
@@ -313,26 +681,59 @@ export const useAppStore = create()(
gradientLevel: state.gradientLevel,
enableHoverGlow: state.enableHoverGlow,
enableBackgroundAnimation: state.enableBackgroundAnimation,
+ motionPreference: state.motionPreference,
locale: state.locale,
sidebarCollapsed: state.sidebarCollapsed,
expandedNavGroups: state.expandedNavGroups,
dashboardLayout: state.dashboardLayout,
+ themeSlots: state.themeSlots,
+ activeSlotId: state.activeSlotId,
}),
onRehydrateStorage: () => (state) => {
- // Apply theme on rehydration
if (state) {
+ // Migrate legacy schema: if no themeSlots, construct from flat fields
+ if (!state.themeSlots || !Array.isArray(state.themeSlots) || state.themeSlots.length === 0) {
+ const migratedSlot: ThemeSlot = {
+ id: 'default',
+ name: 'Default',
+ colorScheme: state.colorScheme ?? 'blue',
+ customHue: state.customHue ?? null,
+ isCustomTheme: (state.customHue ?? null) !== null,
+ gradientLevel: state.gradientLevel ?? 'standard',
+ enableHoverGlow: state.enableHoverGlow ?? true,
+ enableBackgroundAnimation: state.enableBackgroundAnimation ?? false,
+ styleTier: 'standard',
+ isDefault: true,
+ };
+ state.themeSlots = [migratedSlot];
+ state.activeSlotId = 'default';
+ }
+
+ // Ensure activeSlotId is valid
+ if (!state.activeSlotId || !state.themeSlots.find(s => s.id === state.activeSlotId)) {
+ state.activeSlotId = 'default';
+ }
+
+ // Apply theme on rehydration
const resolved = resolveTheme(state.theme);
state.resolvedTheme = resolved;
state.isCustomTheme = state.customHue !== null;
// Apply theme using helper (encapsulates DOM manipulation)
+ const rehydratedStyleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(
resolved,
state.colorScheme,
state.customHue,
state.gradientLevel ?? 'standard',
state.enableHoverGlow ?? true,
- state.enableBackgroundAnimation ?? false
+ state.enableBackgroundAnimation ?? false,
+ state.motionPreference ?? 'system',
+ rehydratedStyleTier
);
+
+ // Apply background config on rehydration
+ const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
+ applyBackgroundToDocument(activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
}
// Apply locale on rehydration
if (state) {
@@ -354,13 +755,16 @@ if (typeof window !== 'undefined') {
const resolved = getSystemTheme();
useAppStore.setState({ resolvedTheme: resolved });
// Apply theme using helper (encapsulates DOM manipulation)
+ const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(
resolved,
state.colorScheme,
state.customHue,
state.gradientLevel,
state.enableHoverGlow,
- state.enableBackgroundAnimation
+ state.enableBackgroundAnimation,
+ state.motionPreference,
+ styleTier
);
}
});
@@ -368,14 +772,21 @@ if (typeof window !== 'undefined') {
// Apply initial theme immediately (before localStorage rehydration)
// This ensures gradient attributes are set from the start
const state = useAppStore.getState();
+ const initialStyleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(
state.resolvedTheme,
state.colorScheme,
state.customHue,
state.gradientLevel,
state.enableHoverGlow,
- state.enableBackgroundAnimation
+ state.enableBackgroundAnimation,
+ state.motionPreference,
+ initialStyleTier
);
+
+ // Apply initial background config
+ const initialActiveSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
+ applyBackgroundToDocument(initialActiveSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
}
// Selectors for common access patterns
@@ -387,8 +798,12 @@ export const selectIsCustomTheme = (state: AppStore) => state.isCustomTheme;
export const selectGradientLevel = (state: AppStore) => state.gradientLevel;
export const selectEnableHoverGlow = (state: AppStore) => state.enableHoverGlow;
export const selectEnableBackgroundAnimation = (state: AppStore) => state.enableBackgroundAnimation;
+export const selectMotionPreference = (state: AppStore) => state.motionPreference;
export const selectLocale = (state: AppStore) => state.locale;
export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen;
export const selectCurrentView = (state: AppStore) => state.currentView;
export const selectIsLoading = (state: AppStore) => state.isLoading;
export const selectError = (state: AppStore) => state.error;
+export const selectThemeSlots = (state: AppStore) => state.themeSlots;
+export const selectActiveSlotId = (state: AppStore) => state.activeSlotId;
+export const selectDeletedSlotBuffer = (state: AppStore) => state.deletedSlotBuffer;
diff --git a/ccw/frontend/src/types/store.ts b/ccw/frontend/src/types/store.ts
index 76202883..e4fdb807 100644
--- a/ccw/frontend/src/types/store.ts
+++ b/ccw/frontend/src/types/store.ts
@@ -8,6 +8,32 @@
export type Theme = 'light' | 'dark' | 'system';
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
export type GradientLevel = 'off' | 'standard' | 'enhanced';
+export type MotionPreference = 'system' | 'reduce' | 'enable';
+export type StyleTier = 'soft' | 'standard' | 'high-contrast';
+export type ThemeSlotId = 'default' | 'custom-1' | 'custom-2';
+export type BackgroundMode = 'gradient-only' | 'image-only' | 'image-gradient';
+
+export interface BackgroundEffects {
+ blur: number; // 0-20 px
+ darkenOpacity: number; // 0-80 %
+ saturation: number; // 0-200 % (100=normal)
+ enableFrostedGlass: boolean;
+ enableGrain: boolean;
+ enableVignette: boolean;
+}
+
+export interface UnsplashAttribution {
+ photographerName: string;
+ photographerUrl: string;
+ photoUrl: string;
+}
+
+export interface BackgroundConfig {
+ mode: BackgroundMode;
+ imageUrl: string | null;
+ attribution: UnsplashAttribution | null;
+ effects: BackgroundEffects;
+}
export type Locale = 'en' | 'zh';
export type ViewMode = 'sessions' | 'liteTasks' | 'project-overview' | 'sessionDetail' | 'liteTaskDetail' | 'loop-monitor' | 'issue-manager' | 'orchestrator';
export type SessionFilter = 'all' | 'active' | 'archived';
@@ -35,6 +61,20 @@ export type LiteTaskType = 'lite-plan' | 'lite-fix' | null;
*/
export type SessionType = 'workflow' | 'review' | 'tdd' | 'test' | 'docs' | 'lite-plan' | 'lite-fix';
+export interface ThemeSlot {
+ id: ThemeSlotId;
+ name: string;
+ colorScheme: ColorScheme;
+ customHue: number | null;
+ isCustomTheme: boolean;
+ gradientLevel: GradientLevel;
+ enableHoverGlow: boolean;
+ enableBackgroundAnimation: boolean;
+ styleTier: StyleTier;
+ isDefault: boolean;
+ backgroundConfig?: BackgroundConfig;
+}
+
export interface AppState {
// Theme
theme: Theme;
@@ -48,6 +88,9 @@ export interface AppState {
enableHoverGlow: boolean; // Enable hover glow effects
enableBackgroundAnimation: boolean; // Enable background gradient animation
+ // Motion preference
+ motionPreference: MotionPreference; // Reduced motion preference: system, reduce, enable
+
// Locale
locale: Locale;
@@ -69,6 +112,11 @@ export interface AppState {
// Dashboard layout
dashboardLayout: DashboardLayoutState | null;
+
+ // Theme slots
+ themeSlots: ThemeSlot[];
+ activeSlotId: ThemeSlotId;
+ deletedSlotBuffer: ThemeSlot | null;
}
export interface AppActions {
@@ -82,6 +130,8 @@ export interface AppActions {
setGradientLevel: (level: GradientLevel) => void;
setEnableHoverGlow: (enabled: boolean) => void;
setEnableBackgroundAnimation: (enabled: boolean) => void;
+ setMotionPreference: (pref: MotionPreference) => void;
+ setStyleTier: (tier: StyleTier) => void;
// Locale actions
setLocale: (locale: Locale) => void;
@@ -107,6 +157,19 @@ export interface AppActions {
setDashboardLayouts: (layouts: DashboardLayouts) => void;
setDashboardWidgets: (widgets: WidgetConfig[]) => void;
resetDashboardLayout: () => void;
+
+ // Theme slot actions
+ setActiveSlot: (slotId: ThemeSlotId) => void;
+ copySlot: () => void;
+ renameSlot: (slotId: ThemeSlotId, name: string) => void;
+ deleteSlot: (slotId: ThemeSlotId) => void;
+ undoDeleteSlot: () => void;
+
+ // Background actions
+ setBackgroundConfig: (config: BackgroundConfig) => void;
+ updateBackgroundEffect: (key: K, value: BackgroundEffects[K]) => void;
+ setBackgroundMode: (mode: BackgroundMode) => void;
+ setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => void;
}
export type AppStore = AppState & AppActions;
diff --git a/ccw/src/core/a2ui/A2UITypes.ts b/ccw/src/core/a2ui/A2UITypes.ts
index 087adc7f..2eff7a30 100644
--- a/ccw/src/core/a2ui/A2UITypes.ts
+++ b/ccw/src/core/a2ui/A2UITypes.ts
@@ -65,9 +65,37 @@ export const QuestionAnswerSchema = z.object({
export type QuestionAnswer = z.infer;
+// ========== AskUserQuestion-style Types ==========
+
+/** AskUserQuestion-style option (value auto-generated from label) */
+export const SimpleOptionSchema = z.object({
+ label: z.string(),
+ description: z.string().optional(),
+});
+
+export type SimpleOption = z.infer;
+
+/** AskUserQuestion-style question */
+export const SimpleQuestionSchema = z.object({
+ question: z.string(), // 问题文本 → 映射到 title
+ header: z.string(), // 短标签 → 映射到 id
+ multiSelect: z.boolean().default(false),
+ options: z.array(SimpleOptionSchema).optional(),
+});
+
+export type SimpleQuestion = z.infer;
+
+/** 新格式参数 (questions 数组) */
+export const AskQuestionSimpleParamsSchema = z.object({
+ questions: z.array(SimpleQuestionSchema).min(1).max(4),
+ timeout: z.number().optional(),
+});
+
+export type AskQuestionSimpleParams = z.infer;
+
// ========== Ask Question Parameters ==========
-/** Parameters for ask_question tool */
+/** Parameters for ask_question tool (legacy format) */
export const AskQuestionParamsSchema = z.object({
question: QuestionSchema,
timeout: z.number().optional().default(300000), // 5 minutes default
diff --git a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts
index 721bed71..234c354f 100644
--- a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts
+++ b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts
@@ -4,10 +4,13 @@
// WebSocket transport for A2UI surfaces and actions
import type { Duplex } from 'stream';
+import http from 'http';
import type { IncomingMessage } from 'http';
import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js';
import type { QuestionAnswer, AskQuestionParams, Question } from './A2UITypes.js';
+const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
+
// ========== A2UI Message Types ==========
/** A2UI WebSocket message types */
@@ -60,8 +63,20 @@ export class A2UIWebSocketHandler {
private multiSelectSelections = new Map>();
private singleSelectSelections = new Map();
+ private inputValues = new Map();
+
+ /** Answers resolved by Dashboard but not yet consumed by MCP polling */
+ private resolvedAnswers = new Map();
+ private resolvedMultiAnswers = new Map();
private answerCallback?: (answer: QuestionAnswer) => boolean;
+ private multiAnswerCallback?: (compositeId: string, answers: QuestionAnswer[]) => boolean;
+
+ /** Buffered surfaces waiting to be replayed to newly connected clients */
+ private pendingSurfaces: Array<{
+ surfaceUpdate: { surfaceId: string; components: unknown[]; initialState: Record; displayMode?: 'popup' | 'panel' };
+ message: unknown;
+ }> = [];
/**
* Register callback for handling question answers
@@ -71,6 +86,14 @@ export class A2UIWebSocketHandler {
this.answerCallback = callback;
}
+ /**
+ * Register callback for handling multi-question composite answers (submit-all)
+ * @param callback - Function to handle composite answers
+ */
+ registerMultiAnswerCallback(callback: (compositeId: string, answers: QuestionAnswer[]) => boolean): void {
+ this.multiAnswerCallback = callback;
+ }
+
/**
* Get the registered answer callback
*/
@@ -78,6 +101,20 @@ export class A2UIWebSocketHandler {
return this.answerCallback;
}
+ /**
+ * Initialize multi-select tracking for a question (used by multi-page surfaces)
+ */
+ initMultiSelect(questionId: string): void {
+ this.multiSelectSelections.set(questionId, new Set());
+ }
+
+ /**
+ * Initialize single-select tracking for a question (used by multi-page surfaces)
+ */
+ initSingleSelect(questionId: string): void {
+ this.singleSelectSelections.set(questionId, '');
+ }
+
/**
* Send A2UI surface to all connected clients
* @param surfaceUpdate - A2UI surface update to send
@@ -115,6 +152,13 @@ export class A2UIWebSocketHandler {
}
}
+ // No local WebSocket clients — forward via HTTP to Dashboard server
+ // (Happens when running in MCP stdio process, separate from Dashboard)
+ if (wsClients.size === 0) {
+ this.forwardSurfaceViaDashboard(surfaceUpdate);
+ return 0;
+ }
+
// Broadcast to all clients
const frame = createWebSocketFrame(message);
let sentCount = 0;
@@ -132,6 +176,72 @@ export class A2UIWebSocketHandler {
return sentCount;
}
+ /**
+ * Replay buffered surfaces to a newly connected client, then clear the buffer.
+ * @param client - The newly connected WebSocket client
+ * @returns Number of surfaces replayed
+ */
+ replayPendingSurfaces(client: Duplex): number {
+ if (this.pendingSurfaces.length === 0) {
+ return 0;
+ }
+
+ const count = this.pendingSurfaces.length;
+ for (const { surfaceUpdate, message } of this.pendingSurfaces) {
+ try {
+ const frame = createWebSocketFrame(message);
+ client.write(frame);
+ } catch (e) {
+ console.error(`[A2UI] Failed to replay surface ${surfaceUpdate.surfaceId}:`, e);
+ }
+ }
+
+ console.log(`[A2UI] Replayed ${count} buffered surface(s) to new client`);
+ this.pendingSurfaces = [];
+ return count;
+ }
+
+ /**
+ * Forward surface to Dashboard server via HTTP POST /api/hook.
+ * Used when running in a separate process (MCP stdio) without local WebSocket clients.
+ */
+ private forwardSurfaceViaDashboard(surfaceUpdate: {
+ surfaceId: string;
+ components: unknown[];
+ initialState: Record;
+ displayMode?: 'popup' | 'panel';
+ }): void {
+ // Send flat so the hook handler wraps it as { type, payload: { ...fields } }
+ // which matches the frontend's expected format: data.type === 'a2ui-surface' && data.payload
+ const body = JSON.stringify({
+ type: 'a2ui-surface',
+ surfaceId: surfaceUpdate.surfaceId,
+ components: surfaceUpdate.components,
+ initialState: surfaceUpdate.initialState,
+ displayMode: surfaceUpdate.displayMode,
+ });
+
+ const req = http.request({
+ hostname: 'localhost',
+ port: DASHBOARD_PORT,
+ path: '/api/hook',
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(body),
+ },
+ });
+
+ req.on('error', (err) => {
+ console.error(`[A2UI] Failed to forward surface ${surfaceUpdate.surfaceId} to Dashboard:`, err.message);
+ });
+
+ req.write(body);
+ req.end();
+
+ console.log(`[A2UI] Forwarded surface ${surfaceUpdate.surfaceId} to Dashboard via HTTP`);
+ }
+
/**
* Send A2UI surface to specific client
* @param client - Specific WebSocket client
@@ -226,12 +336,16 @@ export class A2UIWebSocketHandler {
const resolveAndCleanup = (answer: QuestionAnswer): boolean => {
const handled = answerCallback(answer);
- if (handled) {
- this.activeSurfaces.delete(questionId);
- this.multiSelectSelections.delete(questionId);
- this.singleSelectSelections.delete(questionId);
+ if (!handled) {
+ // answerCallback couldn't deliver (MCP process has no local pendingQuestions)
+ // Store answer for HTTP polling retrieval
+ this.resolvedAnswers.set(questionId, { answer, timestamp: Date.now() });
}
- return handled;
+ // Always clean up UI state regardless of delivery
+ this.activeSurfaces.delete(questionId);
+ this.multiSelectSelections.delete(questionId);
+ this.singleSelectSelections.delete(questionId);
+ return true;
};
switch (action.actionId) {
@@ -278,15 +392,88 @@ export class A2UIWebSocketHandler {
}
case 'submit': {
+ const otherText = this.inputValues.get(`__other__:${questionId}`);
+
// Check if this is a single-select or multi-select
const singleSelection = this.singleSelectSelections.get(questionId);
if (singleSelection !== undefined) {
- // Single-select submit
- return resolveAndCleanup({ questionId, value: singleSelection, cancelled: false });
+ // Resolve __other__ to actual text input
+ const value = singleSelection === '__other__' && otherText ? otherText : singleSelection;
+ this.inputValues.delete(`__other__:${questionId}`);
+ return resolveAndCleanup({ questionId, value, cancelled: false });
}
// Multi-select submit
const multiSelected = this.multiSelectSelections.get(questionId) ?? new Set();
- return resolveAndCleanup({ questionId, value: Array.from(multiSelected), cancelled: false });
+ // Resolve __other__ in multi-select: replace with actual text
+ const values = Array.from(multiSelected).map(v =>
+ v === '__other__' && otherText ? otherText : v
+ );
+ this.inputValues.delete(`__other__:${questionId}`);
+ return resolveAndCleanup({ questionId, value: values, cancelled: false });
+ }
+
+ case 'input-change': {
+ // Track text input value for multi-page surfaces
+ const value = params.value;
+ if (typeof value !== 'string') {
+ return false;
+ }
+ this.inputValues.set(questionId, value);
+ return true;
+ }
+
+ case 'submit-all': {
+ // Multi-question composite submit
+ const compositeId = typeof params.compositeId === 'string' ? params.compositeId : undefined;
+ const questionIds = Array.isArray(params.questionIds) ? params.questionIds as string[] : undefined;
+ if (!compositeId || !questionIds) {
+ return false;
+ }
+
+ // Collect answers for all sub-questions
+ const answers: QuestionAnswer[] = [];
+ for (const qId of questionIds) {
+ const singleSel = this.singleSelectSelections.get(qId);
+ const multiSel = this.multiSelectSelections.get(qId);
+ const inputVal = this.inputValues.get(qId);
+ const otherText = this.inputValues.get(`__other__:${qId}`);
+
+ if (singleSel !== undefined) {
+ // Resolve __other__ to actual text input
+ const value = singleSel === '__other__' && otherText ? otherText : singleSel;
+ answers.push({ questionId: qId, value, cancelled: false });
+ } else if (multiSel !== undefined) {
+ // Resolve __other__ in multi-select: replace with actual text
+ const values = Array.from(multiSel).map(v =>
+ v === '__other__' && otherText ? otherText : v
+ );
+ answers.push({ questionId: qId, value: values, cancelled: false });
+ } else if (inputVal !== undefined) {
+ answers.push({ questionId: qId, value: inputVal, cancelled: false });
+ } else {
+ // No value recorded — include empty
+ answers.push({ questionId: qId, value: '', cancelled: false });
+ }
+
+ // Cleanup per-question tracking
+ this.singleSelectSelections.delete(qId);
+ this.multiSelectSelections.delete(qId);
+ this.inputValues.delete(qId);
+ this.inputValues.delete(`__other__:${qId}`);
+ }
+
+ // Call multi-answer callback
+ let handled = false;
+ if (this.multiAnswerCallback) {
+ handled = this.multiAnswerCallback(compositeId, answers);
+ }
+ if (!handled) {
+ // Store for HTTP polling retrieval
+ this.resolvedMultiAnswers.set(compositeId, { compositeId, answers, timestamp: Date.now() });
+ }
+ // Always clean up UI state
+ this.activeSurfaces.delete(compositeId);
+ return true;
}
default:
@@ -324,6 +511,7 @@ export class A2UIWebSocketHandler {
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
+ this.inputValues.delete(questionId);
return true;
}
@@ -346,6 +534,32 @@ export class A2UIWebSocketHandler {
this.activeSurfaces.clear();
}
+ /**
+ * Get and remove a resolved answer (one-shot read).
+ * Used by MCP HTTP polling to retrieve answers stored by the Dashboard.
+ */
+ getResolvedAnswer(questionId: string): QuestionAnswer | undefined {
+ const entry = this.resolvedAnswers.get(questionId);
+ if (entry) {
+ this.resolvedAnswers.delete(questionId);
+ return entry.answer;
+ }
+ return undefined;
+ }
+
+ /**
+ * Get and remove a resolved multi-answer (one-shot read).
+ * Used by MCP HTTP polling to retrieve composite answers stored by the Dashboard.
+ */
+ getResolvedMultiAnswer(compositeId: string): QuestionAnswer[] | undefined {
+ const entry = this.resolvedMultiAnswers.get(compositeId);
+ if (entry) {
+ this.resolvedMultiAnswers.delete(compositeId);
+ return entry.answers;
+ }
+ return undefined;
+ }
+
/**
* Remove stale surfaces (older than specified time)
* @param maxAge - Maximum age in milliseconds
@@ -362,6 +576,18 @@ export class A2UIWebSocketHandler {
}
}
+ // Clean up stale resolved answers
+ for (const [id, entry] of this.resolvedAnswers) {
+ if (now - entry.timestamp > maxAge) {
+ this.resolvedAnswers.delete(id);
+ }
+ }
+ for (const [id, entry] of this.resolvedMultiAnswers) {
+ if (now - entry.timestamp > maxAge) {
+ this.resolvedMultiAnswers.delete(id);
+ }
+ }
+
return removed;
}
}
diff --git a/ccw/src/core/routes/codexlens/index-handlers.ts b/ccw/src/core/routes/codexlens/index-handlers.ts
index cc907a1e..923a2f7b 100644
--- a/ccw/src/core/routes/codexlens/index-handlers.ts
+++ b/ccw/src/core/routes/codexlens/index-handlers.ts
@@ -259,7 +259,8 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise,
+ timeout: number = 60000
+): Promise<{ success: boolean; results?: unknown; error?: string }> {
+ return new Promise((resolve) => {
+ const pythonScript = `
+import json
+import sys
+from dataclasses import is_dataclass, asdict
+from codexlens.api import ${apiFunction}
+
+def to_serializable(obj):
+ if obj is None:
+ return None
+ if is_dataclass(obj) and not isinstance(obj, type):
+ return asdict(obj)
+ if isinstance(obj, list):
+ return [to_serializable(item) for item in obj]
+ if isinstance(obj, dict):
+ return {key: to_serializable(value) for key, value in obj.items()}
+ if isinstance(obj, tuple):
+ return tuple(to_serializable(item) for item in obj)
+ return obj
+
+try:
+ args = ${JSON.stringify(args)}
+ result = ${apiFunction}(**args)
+ output = to_serializable(result)
+ print(json.dumps({"success": True, "result": output}))
+except Exception as e:
+ print(json.dumps({"success": False, "error": str(e)}))
+ sys.exit(1)
+`;
+
+ const pythonPath = getCodexLensPython();
+ const child = spawn(pythonPath, ['-c', pythonScript], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ timeout,
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout.on('data', (data: Buffer) => {
+ stdout += data.toString();
+ });
+
+ child.stderr.on('data', (data: Buffer) => {
+ stderr += data.toString();
+ });
+
+ child.on('close', (code) => {
+ if (code !== 0) {
+ try {
+ const errorData = JSON.parse(stderr || stdout);
+ resolve({ success: false, error: errorData.error || 'Unknown error' });
+ } catch {
+ resolve({ success: false, error: stderr || stdout || `Process exited with code ${code}` });
+ }
+ return;
+ }
+
+ try {
+ const data = JSON.parse(stdout);
+ resolve({ success: data.success, results: data.result, error: data.error });
+ } catch (err) {
+ resolve({ success: false, error: `Failed to parse output: ${(err as Error).message}` });
+ }
+ });
+
+ child.on('error', (err) => {
+ resolve({ success: false, error: `Failed to execute: ${err.message}` });
+ });
+ });
+}
+
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
@@ -928,5 +1010,154 @@ except Exception as e:
return true;
}
+ // ============================================================
+ // LSP / SEMANTIC SEARCH API ENDPOINTS
+ // ============================================================
+
+ // API: LSP Status - Check if LSP/semantic search capabilities are available
+ if (pathname === '/api/codexlens/lsp/status') {
+ try {
+ const venvStatus = await checkVenvStatus();
+ if (!venvStatus.ready) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ available: false,
+ semantic_available: false,
+ vector_index: false,
+ error: 'CodexLens not installed'
+ }));
+ return true;
+ }
+
+ // Check semantic deps and vector index availability in parallel
+ const [semanticStatus, workspaceResult] = await Promise.all([
+ checkSemanticStatus(),
+ executeCodexLens(['status', '--json'])
+ ]);
+
+ let hasVectorIndex = false;
+ let projectCount = 0;
+ let embeddingsInfo: Record = {};
+
+ if (workspaceResult.success) {
+ try {
+ const status = extractJSON(workspaceResult.output ?? '');
+ if (status.success !== false && status.result) {
+ projectCount = status.result.projects_count || 0;
+ embeddingsInfo = status.result.embeddings || {};
+ // Check if any projects have embeddings
+ hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
+ } else if (status.projects_count !== undefined) {
+ projectCount = status.projects_count || 0;
+ embeddingsInfo = status.embeddings || {};
+ hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
+ }
+ } catch {
+ // Parse failed
+ }
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ available: semanticStatus.available && hasVectorIndex,
+ semantic_available: semanticStatus.available,
+ vector_index: hasVectorIndex,
+ project_count: projectCount,
+ embeddings: embeddingsInfo,
+ modes: ['fusion', 'vector', 'structural'],
+ strategies: ['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'],
+ }));
+ } catch (err: unknown) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ available: false,
+ semantic_available: false,
+ vector_index: false,
+ error: err instanceof Error ? err.message : String(err)
+ }));
+ }
+ return true;
+ }
+
+ // API: LSP Semantic Search - Advanced semantic search via Python API
+ if (pathname === '/api/codexlens/lsp/search' && req.method === 'POST') {
+ handlePostRequest(req, res, async (body) => {
+ const {
+ query,
+ path: projectPath,
+ mode = 'fusion',
+ fusion_strategy = 'rrf',
+ vector_weight = 0.5,
+ structural_weight = 0.3,
+ keyword_weight = 0.2,
+ kind_filter,
+ limit = 20,
+ include_match_reason = false,
+ } = body as {
+ query?: unknown;
+ path?: unknown;
+ mode?: unknown;
+ fusion_strategy?: unknown;
+ vector_weight?: unknown;
+ structural_weight?: unknown;
+ keyword_weight?: unknown;
+ kind_filter?: unknown;
+ limit?: unknown;
+ include_match_reason?: unknown;
+ };
+
+ const resolvedQuery = typeof query === 'string' ? query.trim() : '';
+ if (!resolvedQuery) {
+ return { success: false, error: 'Query parameter is required', status: 400 };
+ }
+
+ const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
+ const resolvedMode = typeof mode === 'string' && ['fusion', 'vector', 'structural'].includes(mode) ? mode : 'fusion';
+ const resolvedStrategy = typeof fusion_strategy === 'string' &&
+ ['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'].includes(fusion_strategy) ? fusion_strategy : 'rrf';
+ const resolvedVectorWeight = typeof vector_weight === 'number' ? vector_weight : 0.5;
+ const resolvedStructuralWeight = typeof structural_weight === 'number' ? structural_weight : 0.3;
+ const resolvedKeywordWeight = typeof keyword_weight === 'number' ? keyword_weight : 0.2;
+ const resolvedLimit = typeof limit === 'number' ? limit : 20;
+ const resolvedIncludeReason = typeof include_match_reason === 'boolean' ? include_match_reason : false;
+
+ // Build Python API call args
+ const apiArgs: Record = {
+ project_root: targetPath,
+ query: resolvedQuery,
+ mode: resolvedMode,
+ vector_weight: resolvedVectorWeight,
+ structural_weight: resolvedStructuralWeight,
+ keyword_weight: resolvedKeywordWeight,
+ fusion_strategy: resolvedStrategy,
+ limit: resolvedLimit,
+ include_match_reason: resolvedIncludeReason,
+ };
+
+ if (Array.isArray(kind_filter) && kind_filter.length > 0) {
+ apiArgs.kind_filter = kind_filter;
+ }
+
+ try {
+ const result = await executeCodexLensPythonAPI('semantic_search', apiArgs);
+ if (result.success) {
+ return {
+ success: true,
+ results: result.results,
+ query: resolvedQuery,
+ mode: resolvedMode,
+ fusion_strategy: resolvedStrategy,
+ count: Array.isArray(result.results) ? result.results.length : 0,
+ };
+ } else {
+ return { success: false, error: result.error || 'Semantic search failed', status: 500 };
+ }
+ } catch (err: unknown) {
+ return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
+ }
+ });
+ return true;
+ }
+
return false;
}
diff --git a/ccw/src/core/routes/mcp-routes.ts b/ccw/src/core/routes/mcp-routes.ts
index 48dfc036..31cb0cbc 100644
--- a/ccw/src/core/routes/mcp-routes.ts
+++ b/ccw/src/core/routes/mcp-routes.ts
@@ -1197,7 +1197,7 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise {
// Parse enabled tools from request body
const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0
? (body.enabledTools as string[]).join(',')
- : 'write_file,edit_file,read_file,core_memory,ask_question';
+ : 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
// Generate CCW MCP server config
// Use cmd /c on Windows to inherit Claude Code's working directory
diff --git a/ccw/src/core/routes/system-routes.ts b/ccw/src/core/routes/system-routes.ts
index 4371f55e..b003d48d 100644
--- a/ccw/src/core/routes/system-routes.ts
+++ b/ccw/src/core/routes/system-routes.ts
@@ -648,5 +648,104 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise = {
+ 'image/jpeg': 'jpg',
+ 'image/png': 'png',
+ 'image/webp': 'webp',
+ 'image/gif': 'gif',
+};
+const MIME_MAP: Record = {
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ png: 'image/png',
+ webp: 'image/webp',
+ gif: 'image/gif',
+};
+
+function getAccessKey(): string | undefined {
+ return process.env.UNSPLASH_ACCESS_KEY;
+}
+
+interface UnsplashPhoto {
+ id: string;
+ urls: { thumb: string; small: string; regular: string };
+ user: { name: string; links: { html: string } };
+ links: { html: string; download_location: string };
+ blur_hash: string | null;
+}
+
+interface UnsplashSearchResult {
+ results: UnsplashPhoto[];
+ total: number;
+ total_pages: number;
+}
+
+export async function handleBackgroundRoutes(ctx: RouteContext): Promise {
+ const { pathname, req, res } = ctx;
+
+ // POST /api/background/upload
+ if (pathname === '/api/background/upload' && req.method === 'POST') {
+ const contentType = req.headers['content-type'] || '';
+ if (!ALLOWED_TYPES.has(contentType)) {
+ res.writeHead(415, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unsupported image type. Only JPEG, PNG, WebP, GIF allowed.' }));
+ return true;
+ }
+
+ try {
+ const chunks: Buffer[] = [];
+ let totalSize = 0;
+
+ for await (const chunk of req) {
+ totalSize += chunk.length;
+ if (totalSize > MAX_UPLOAD_SIZE) {
+ res.writeHead(413, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'File too large. Maximum size is 10MB.' }));
+ return true;
+ }
+ chunks.push(chunk);
+ }
+
+ const buffer = Buffer.concat(chunks);
+ const ext = EXT_MAP[contentType] || 'bin';
+ const filename = `${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`;
+
+ mkdirSync(UPLOADS_DIR, { recursive: true });
+ writeFileSync(join(UPLOADS_DIR, filename), buffer);
+
+ const url = `/api/background/uploads/${filename}`;
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ url, filename }));
+ } catch (err) {
+ console.error('[background] Upload error:', err);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Upload failed' }));
+ }
+ return true;
+ }
+
+ // GET /api/background/uploads/:filename
+ if (pathname.startsWith('/api/background/uploads/') && req.method === 'GET') {
+ const filename = pathname.slice('/api/background/uploads/'.length);
+
+ // Security: reject path traversal
+ if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid filename' }));
+ return true;
+ }
+
+ const filePath = join(UPLOADS_DIR, filename);
+ if (!existsSync(filePath)) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'File not found' }));
+ return true;
+ }
+
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
+ const mime = MIME_MAP[ext] || 'application/octet-stream';
+
+ try {
+ const data = readFileSync(filePath);
+ res.writeHead(200, {
+ 'Content-Type': mime,
+ 'Content-Length': data.length,
+ 'Cache-Control': 'public, max-age=86400',
+ });
+ res.end(data);
+ } catch (err) {
+ console.error('[background] Serve error:', err);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Failed to read file' }));
+ }
+ return true;
+ }
+
+ return false;
+}
+
+export async function handleUnsplashRoutes(ctx: RouteContext): Promise {
+ const { pathname, url, req, res } = ctx;
+
+ // GET /api/unsplash/search?query=...&page=1&per_page=20
+ if (pathname === '/api/unsplash/search' && req.method === 'GET') {
+ const accessKey = getAccessKey();
+ if (!accessKey) {
+ res.writeHead(503, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
+ return true;
+ }
+
+ const query = url.searchParams.get('query') || '';
+ const page = url.searchParams.get('page') || '1';
+ const perPage = url.searchParams.get('per_page') || '20';
+
+ if (!query.trim()) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Missing query parameter' }));
+ return true;
+ }
+
+ try {
+ const apiUrl = `${UNSPLASH_API}/search/photos?query=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&orientation=landscape`;
+ const response = await fetch(apiUrl, {
+ headers: { Authorization: `Client-ID ${accessKey}` },
+ });
+
+ if (!response.ok) {
+ const status = response.status;
+ res.writeHead(status, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: `Unsplash API error: ${status}` }));
+ return true;
+ }
+
+ const data = (await response.json()) as UnsplashSearchResult;
+
+ // Return simplified data
+ const photos = data.results.map((photo) => ({
+ id: photo.id,
+ thumbUrl: photo.urls.thumb,
+ smallUrl: photo.urls.small,
+ regularUrl: photo.urls.regular,
+ photographer: photo.user.name,
+ photographerUrl: photo.user.links.html,
+ photoUrl: photo.links.html,
+ blurHash: photo.blur_hash,
+ downloadLocation: photo.links.download_location,
+ }));
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ photos,
+ total: data.total,
+ totalPages: data.total_pages,
+ }));
+ } catch (err) {
+ console.error('[unsplash] Search error:', err);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Failed to search Unsplash' }));
+ }
+ return true;
+ }
+
+ // POST /api/unsplash/download — trigger download event (Unsplash API requirement)
+ if (pathname === '/api/unsplash/download' && req.method === 'POST') {
+ const accessKey = getAccessKey();
+ if (!accessKey) {
+ res.writeHead(503, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
+ return true;
+ }
+
+ try {
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) {
+ chunks.push(chunk);
+ }
+ const body = JSON.parse(Buffer.concat(chunks).toString());
+ const downloadLocation = body.downloadLocation;
+
+ if (!downloadLocation) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Missing downloadLocation' }));
+ return true;
+ }
+
+ // Trigger download event (Unsplash API guideline)
+ await fetch(downloadLocation, {
+ headers: { Authorization: `Client-ID ${accessKey}` },
+ });
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+ } catch (err) {
+ console.error('[unsplash] Download trigger error:', err);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Failed to trigger download' }));
+ }
+ return true;
+ }
+
+ return false;
+}
diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts
index fd0dcdb6..d5fc32c2 100644
--- a/ccw/src/core/server.ts
+++ b/ccw/src/core/server.ts
@@ -13,6 +13,7 @@ import { handleMemoryRoutes } from './routes/memory-routes.js';
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js';
import { handleHooksRoutes } from './routes/hooks-routes.js';
+import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js';
@@ -461,7 +462,7 @@ export async function startServer(options: ServerOptions = {}): Promise(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question']);
+ const unauthenticatedPaths = new Set(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
@@ -627,6 +628,16 @@ export async function startServer(options: ServerOptions = {}): Promise {
beforeEach(() => {
@@ -44,8 +46,11 @@ describe('ask_question Tool', () => {
};
// Should not throw during validation
- const result = await execute(params);
- expect(result).toBeDefined();
+ const executePromise = execute(params);
+ expect(getPendingQuestions()).toHaveLength(1);
+
+ cancelQuestion('test-question-1');
+ await executePromise;
});
it('should validate a valid select question with options', async () => {
@@ -60,9 +65,11 @@ describe('ask_question Tool', () => {
};
const params: AskQuestionParams = { question };
- const result = await execute(params);
+ const executePromise = execute(params);
+ expect(getPendingQuestions()).toHaveLength(1);
- expect(result).toBeDefined();
+ cancelQuestion('test-select');
+ await executePromise;
});
it('should validate a valid input question', async () => {
@@ -74,9 +81,11 @@ describe('ask_question Tool', () => {
};
const params: AskQuestionParams = { question };
- const result = await execute(params);
+ const executePromise = execute(params);
+ expect(getPendingQuestions()).toHaveLength(1);
- expect(result).toBeDefined();
+ cancelQuestion('test-input');
+ await executePromise;
});
it('should validate a valid multi-select question', async () => {
@@ -92,9 +101,11 @@ describe('ask_question Tool', () => {
};
const params: AskQuestionParams = { question };
- const result = await execute(params);
+ const executePromise = execute(params);
+ expect(getPendingQuestions()).toHaveLength(1);
- expect(result).toBeDefined();
+ cancelQuestion('test-multi');
+ await executePromise;
});
it('should reject question with missing id', async () => {
@@ -162,7 +173,7 @@ describe('ask_question Tool', () => {
const result = await execute(params);
expect(result.success).toBe(false);
- expect(result.error).toContain('options');
+ expect(result.error).toContain('option');
});
it('should reject options with missing value', async () => {
@@ -554,10 +565,9 @@ describe('ask_question Tool', () => {
id: 'test-timeout',
type: 'confirm',
title: 'Test',
- timeout: 5000, // 5 seconds
};
- const params: AskQuestionParams = { question };
+ const params: AskQuestionParams = { question, timeout: 5000 };
const executePromise = execute(params);
// Fast-forward time
@@ -658,7 +668,8 @@ describe('ask_question Tool', () => {
};
const params1: AskQuestionParams = { question };
- const executePromise1 = execute(params1);
+ // Don't await — first promise becomes orphaned when id is reused
+ execute(params1);
// Second execution with same ID should replace first
const question2: Question = {
@@ -671,10 +682,11 @@ describe('ask_question Tool', () => {
// There should still be only one pending
expect(getPendingQuestions()).toHaveLength(1);
+ expect(getPendingQuestions()[0].question.title).toBe('Second');
- // Clean up
+ // Clean up the active pending question
cancelQuestion('duplicate-id');
- await Promise.all([executePromise1, executePromise2]);
+ await executePromise2;
});
it('should handle answer after question is cancelled', async () => {
@@ -730,4 +742,204 @@ describe('ask_question Tool', () => {
}
});
});
+
+ describe('AskUserQuestion-style Format (via handler)', () => {
+ it('should handle single select question', async () => {
+ const params = {
+ questions: [{
+ question: 'Which library?',
+ header: 'Library',
+ multiSelect: false,
+ options: [
+ { label: 'React', description: 'UI library' },
+ { label: 'Vue', description: 'Progressive framework' },
+ ],
+ }],
+ };
+
+ const handlerPromise = handler(params);
+
+ // Answer the normalized question (id = header)
+ const pending = getPendingQuestions();
+ expect(pending).toHaveLength(1);
+ expect(pending[0].id).toBe('Library');
+ expect(pending[0].question.type).toBe('select');
+ expect(pending[0].question.title).toBe('Which library?');
+
+ // Options should use label as value
+ expect(pending[0].question.options).toEqual([
+ { value: 'React', label: 'React', description: 'UI library' },
+ { value: 'Vue', label: 'Vue', description: 'Progressive framework' },
+ ]);
+
+ const answer: QuestionAnswer = {
+ questionId: 'Library',
+ value: 'React',
+ };
+ handleAnswer(answer);
+
+ const result = await handlerPromise;
+ expect(result.success).toBe(true);
+ expect((result.result as any).answersDict).toEqual({ Library: 'React' });
+ });
+
+ it('should handle multiSelect question', async () => {
+ const params = {
+ questions: [{
+ question: 'Which features?',
+ header: 'Features',
+ multiSelect: true,
+ options: [
+ { label: 'Auth', description: 'Authentication' },
+ { label: 'Cache', description: 'Caching layer' },
+ { label: 'Logging' },
+ ],
+ }],
+ };
+
+ const handlerPromise = handler(params);
+
+ const pending = getPendingQuestions();
+ expect(pending[0].question.type).toBe('multi-select');
+
+ const answer: QuestionAnswer = {
+ questionId: 'Features',
+ value: ['Auth', 'Logging'],
+ };
+ handleAnswer(answer);
+
+ const result = await handlerPromise;
+ expect(result.success).toBe(true);
+ expect((result.result as any).answersDict).toEqual({ Features: ['Auth', 'Logging'] });
+ });
+
+ it('should handle input question (no options)', async () => {
+ const params = {
+ questions: [{
+ question: 'What is your name?',
+ header: 'Name',
+ multiSelect: false,
+ }],
+ };
+
+ const handlerPromise = handler(params);
+
+ const pending = getPendingQuestions();
+ expect(pending[0].question.type).toBe('input');
+
+ const answer: QuestionAnswer = {
+ questionId: 'Name',
+ value: 'John',
+ };
+ handleAnswer(answer);
+
+ const result = await handlerPromise;
+ expect(result.success).toBe(true);
+ expect((result.result as any).answersDict).toEqual({ Name: 'John' });
+ });
+
+ it('should handle multiple questions in single multi-page surface', async () => {
+ const params = {
+ questions: [
+ {
+ question: 'Which library?',
+ header: 'Library',
+ multiSelect: false,
+ options: [
+ { label: 'React' },
+ { label: 'Vue' },
+ ],
+ },
+ {
+ question: 'Which level?',
+ header: 'Level',
+ multiSelect: false,
+ options: [
+ { label: 'Beginner' },
+ { label: 'Advanced' },
+ ],
+ },
+ ],
+ };
+
+ const handlerPromise = handler(params);
+
+ // A single composite question should be pending
+ const pending = getPendingQuestions();
+ expect(pending).toHaveLength(1);
+ expect(pending[0].id).toMatch(/^multi-/);
+
+ const compositeId = pending[0].id;
+
+ // Simulate submit-all with answers for all pages
+ handleMultiAnswer(compositeId, [
+ { questionId: 'Library', value: 'React', cancelled: false },
+ { questionId: 'Level', value: 'Advanced', cancelled: false },
+ ]);
+
+ const result = await handlerPromise;
+ expect(result.success).toBe(true);
+ expect((result.result as any).answersDict).toEqual({
+ Library: 'React',
+ Level: 'Advanced',
+ });
+ });
+
+ it('should cancel multi-question composite on cancel', async () => {
+ const params = {
+ questions: [
+ {
+ question: 'First?',
+ header: 'Q1',
+ multiSelect: false,
+ options: [{ label: 'A' }],
+ },
+ {
+ question: 'Second?',
+ header: 'Q2',
+ multiSelect: false,
+ options: [{ label: 'B' }],
+ },
+ ],
+ };
+
+ const handlerPromise = handler(params);
+
+ // Cancel the composite question
+ const pending = getPendingQuestions();
+ expect(pending).toHaveLength(1);
+ cancelQuestion(pending[0].id);
+
+ const result = await handlerPromise;
+ expect(result.success).toBe(true);
+ expect(result.result?.cancelled).toBe(true);
+ });
+
+ it('should still support legacy format via handler', async () => {
+ const params = {
+ question: {
+ id: 'legacy-test',
+ type: 'confirm',
+ title: 'Legacy question?',
+ },
+ };
+
+ const handlerPromise = handler(params as any);
+
+ const pending = getPendingQuestions();
+ expect(pending).toHaveLength(1);
+ expect(pending[0].id).toBe('legacy-test');
+
+ const answer: QuestionAnswer = {
+ questionId: 'legacy-test',
+ value: true,
+ };
+ handleAnswer(answer);
+
+ const result = await handlerPromise;
+ expect(result.success).toBe(true);
+ // Legacy format should NOT have answersDict
+ expect((result.result as any).answersDict).toBeUndefined();
+ });
+ });
});
diff --git a/ccw/src/tools/ask-question.ts b/ccw/src/tools/ask-question.ts
index e377ce2d..a49f4a98 100644
--- a/ccw/src/tools/ask-question.ts
+++ b/ccw/src/tools/ask-question.ts
@@ -13,9 +13,19 @@ import type {
AskQuestionParams,
AskQuestionResult,
PendingQuestion,
+ SimpleQuestion,
} from '../core/a2ui/A2UITypes.js';
+import http from 'http';
import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js';
+const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
+const POLL_INTERVAL_MS = 1000;
+
+// Register multi-answer callback for multi-page question surfaces
+a2uiWebSocketHandler.registerMultiAnswerCallback(
+ (compositeId: string, answers: QuestionAnswer[]) => handleMultiAnswer(compositeId, answers)
+);
+
// ========== Constants ==========
/** Default question timeout (5 minutes) */
@@ -114,6 +124,10 @@ function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
if (!question.options) {
return false;
}
+ // Accept __other__ as a valid value (custom input)
+ if (answer.value === '__other__' || answer.value.startsWith('__other__:')) {
+ return true;
+ }
return question.options.some((opt) => opt.value === answer.value);
case 'multi-select':
@@ -124,13 +138,51 @@ function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
return false;
}
const validValues = new Set(question.options.map((opt) => opt.value));
- return answer.value.every((v) => validValues.has(v));
+ // Accept __other__ as a valid value (custom input)
+ validValues.add('__other__');
+ return answer.value.every((v) => typeof v === 'string' && (validValues.has(v) || v.startsWith('__other__:')));
default:
return false;
}
}
+// ========== Simple Format Normalization ==========
+
+/**
+ * Normalize a SimpleQuestion (AskUserQuestion-style) to internal Question format
+ * @param simple - SimpleQuestion to normalize
+ * @returns Normalized Question
+ */
+function normalizeSimpleQuestion(simple: SimpleQuestion): Question {
+ let type: QuestionType;
+ if (simple.options && simple.options.length > 0) {
+ type = simple.multiSelect ? 'multi-select' : 'select';
+ } else {
+ type = 'input';
+ }
+
+ const options: QuestionOption[] | undefined = simple.options?.map((opt) => ({
+ value: opt.label,
+ label: opt.label,
+ description: opt.description,
+ }));
+
+ return {
+ id: simple.header,
+ type,
+ title: simple.question,
+ options,
+ } as Question;
+}
+
+/**
+ * Detect if params use the new "questions" array format
+ */
+function isSimpleFormat(params: Record): params is { questions: SimpleQuestion[]; timeout?: number } {
+ return Array.isArray(params.questions);
+}
+
// ========== A2UI Surface Generation ==========
/**
@@ -223,6 +275,13 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
+ // Add "Other" option for custom input
+ options.push({
+ label: { literalString: 'Other' },
+ value: '__other__',
+ description: { literalString: 'Provide a custom answer' },
+ });
+
// Use RadioGroup for direct selection display (not dropdown)
components.push({
id: 'radio-group',
@@ -267,6 +326,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
+ description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
// Add each checkbox as a separate component for better layout control
@@ -276,6 +336,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
component: {
Checkbox: {
label: opt.label,
+ ...(opt.description && { description: opt.description }),
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } },
checked: { literalBoolean: false },
},
@@ -283,6 +344,19 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
});
});
+ // Add "Other" checkbox for custom input
+ components.push({
+ id: 'checkbox-other',
+ component: {
+ Checkbox: {
+ label: { literalString: 'Other' },
+ description: { literalString: 'Provide a custom answer' },
+ onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: '__other__' } },
+ checked: { literalBoolean: false },
+ },
+ },
+ });
+
// Submit/cancel actions for multi-select so users can choose multiple options before resolving
components.push({
id: 'submit-btn',
@@ -390,7 +464,12 @@ export async function execute(params: AskQuestionParams): Promise {
+ // Stop if the question was already resolved or timed out
+ if (!pendingQuestions.has(questionId)) {
+ return;
+ }
+
+ const req = http.get({ hostname: 'localhost', port: DASHBOARD_PORT, path }, (res) => {
+ let data = '';
+ res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
+ res.on('end', () => {
+ try {
+ const parsed = JSON.parse(data);
+ if (parsed.pending) {
+ // No answer yet, schedule next poll
+ setTimeout(poll, POLL_INTERVAL_MS);
+ return;
+ }
+
+ if (isComposite && Array.isArray(parsed.answers)) {
+ handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
+ } else if (!isComposite && parsed.answer) {
+ handleAnswer(parsed.answer as QuestionAnswer);
+ } else {
+ // Unexpected shape, keep polling
+ setTimeout(poll, POLL_INTERVAL_MS);
+ }
+ } catch {
+ // Parse error, keep polling
+ setTimeout(poll, POLL_INTERVAL_MS);
+ }
+ });
+ });
+
+ req.on('error', () => {
+ // Network error (Dashboard not reachable), keep trying
+ if (pendingQuestions.has(questionId)) {
+ setTimeout(poll, POLL_INTERVAL_MS);
+ }
+ });
+ };
+
+ // Start first poll after a short delay to give the Dashboard time to receive the surface
+ setTimeout(poll, POLL_INTERVAL_MS);
+}
+
// ========== Cleanup ==========
/**
@@ -488,12 +646,70 @@ export function clearPendingQuestions(): void {
export const schema: ToolSchema = {
name: 'ask_question',
- description: 'Ask the user a question through an interactive A2UI interface. Supports confirmation dialogs, selection from options, text input, and multi-select checkboxes.',
+ description: `Ask the user a question through an interactive A2UI interface. Supports two calling styles:
+
+**Style 1 - AskUserQuestion-compatible (recommended)**:
+\`\`\`json
+{
+ "questions": [{
+ "question": "Which library?",
+ "header": "Library",
+ "multiSelect": false,
+ "options": [
+ { "label": "React", "description": "UI library" },
+ { "label": "Vue", "description": "Progressive framework" }
+ ]
+ }]
+}
+\`\`\`
+Response includes \`answersDict\`: \`{ "Library": "React" }\`
+
+Type inference: options + multiSelect=true → multi-select; options + multiSelect=false → select; no options → input.
+
+**Style 2 - Legacy format**:
+\`\`\`json
+{
+ "question": {
+ "id": "q1",
+ "type": "select",
+ "title": "Which library?",
+ "options": [{ "value": "react", "label": "React" }]
+ }
+}
+\`\`\``,
inputSchema: {
type: 'object',
properties: {
+ questions: {
+ type: 'array',
+ description: 'AskUserQuestion-style questions array (1-4 questions). Use this OR "question", not both.',
+ items: {
+ type: 'object',
+ properties: {
+ question: { type: 'string', description: 'The question text' },
+ header: { type: 'string', description: 'Short label, also used as response key (max 12 chars)' },
+ multiSelect: { type: 'boolean', description: 'Allow multiple selections (default: false)' },
+ options: {
+ type: 'array',
+ description: 'Available choices. Omit for text input.',
+ items: {
+ type: 'object',
+ properties: {
+ label: { type: 'string', description: 'Display text, also used as value' },
+ description: { type: 'string', description: 'Option description' },
+ },
+ required: ['label'],
+ },
+ },
+ },
+ required: ['question', 'header'],
+ },
+ minItems: 1,
+ maxItems: 4,
+ },
question: {
type: 'object',
+ description: 'Legacy format: single question object. Use this OR "questions", not both.',
properties: {
id: { type: 'string', description: 'Unique identifier for this question' },
type: {
@@ -524,16 +740,343 @@ export const schema: ToolSchema = {
required: ['id', 'type', 'title'],
},
timeout: { type: 'number', description: 'Timeout in milliseconds (default: 300000 / 5 minutes)' },
- surfaceId: { type: 'string', description: 'Custom surface ID (auto-generated if not provided)' },
+ surfaceId: { type: 'string', description: 'Custom surface ID (auto-generated if not provided). Legacy format only.' },
},
- required: ['question'],
},
};
/**
* Tool handler for MCP integration
- * Wraps the execute function to match the expected handler signature
+ * Supports both legacy format (question object) and AskUserQuestion-style format (questions array)
*/
export async function handler(params: Record): Promise> {
+ if (isSimpleFormat(params)) {
+ return executeSimpleFormat(params.questions, params.timeout);
+ }
return execute(params as AskQuestionParams);
}
+
+// ========== Multi-Question Surface Generation ==========
+
+/**
+ * Page metadata for multi-question surfaces
+ */
+interface PageMeta {
+ index: number;
+ questionId: string;
+ title: string;
+ type: string;
+}
+
+/**
+ * Generate a single A2UI surface containing all questions, each tagged with a page index.
+ * @param questions - Array of SimpleQuestion
+ * @returns Surface update with page-tagged components and page metadata
+ */
+function generateMultiQuestionSurface(
+ questions: SimpleQuestion[],
+ surfaceId: string,
+): {
+ surfaceUpdate: {
+ surfaceId: string;
+ components: unknown[];
+ initialState: Record;
+ displayMode: 'popup';
+ };
+ pages: PageMeta[];
+} {
+ const components: unknown[] = [];
+ const pages: PageMeta[] = [];
+
+ for (let pageIdx = 0; pageIdx < questions.length; pageIdx++) {
+ const simpleQ = questions[pageIdx];
+ const question = normalizeSimpleQuestion(simpleQ);
+ const qId = question.id; // header used as id
+
+ pages.push({
+ index: pageIdx,
+ questionId: qId,
+ title: question.title,
+ type: question.type,
+ });
+
+ // Title
+ components.push({
+ id: `page-${pageIdx}-title`,
+ page: pageIdx,
+ component: {
+ Text: {
+ text: { literalString: question.title },
+ usageHint: 'h3',
+ },
+ },
+ });
+
+ // Message
+ if (question.message) {
+ components.push({
+ id: `page-${pageIdx}-message`,
+ page: pageIdx,
+ component: {
+ Text: {
+ text: { literalString: question.message },
+ usageHint: 'p',
+ },
+ },
+ });
+ }
+
+ // Description
+ if (question.description) {
+ components.push({
+ id: `page-${pageIdx}-description`,
+ page: pageIdx,
+ component: {
+ Text: {
+ text: { literalString: question.description },
+ usageHint: 'small',
+ },
+ },
+ });
+ }
+
+ // Interactive components based on question type
+ switch (question.type) {
+ case 'select': {
+ const options = question.options?.map((opt) => ({
+ label: { literalString: opt.label },
+ value: opt.value,
+ description: opt.description ? { literalString: opt.description } : undefined,
+ })) || [];
+
+ // Add "Other" option for custom input
+ options.push({
+ label: { literalString: 'Other' },
+ value: '__other__',
+ description: { literalString: 'Provide a custom answer' },
+ });
+
+ components.push({
+ id: `page-${pageIdx}-radio-group`,
+ page: pageIdx,
+ component: {
+ RadioGroup: {
+ options,
+ selectedValue: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
+ onChange: { actionId: 'select', parameters: { questionId: qId } },
+ },
+ },
+ });
+ break;
+ }
+
+ case 'multi-select': {
+ const options = question.options?.map((opt) => ({
+ label: { literalString: opt.label },
+ value: opt.value,
+ description: opt.description ? { literalString: opt.description } : undefined,
+ })) || [];
+
+ options.forEach((opt, idx) => {
+ components.push({
+ id: `page-${pageIdx}-checkbox-${idx}`,
+ page: pageIdx,
+ component: {
+ Checkbox: {
+ label: opt.label,
+ ...(opt.description && { description: opt.description }),
+ onChange: { actionId: 'toggle', parameters: { questionId: qId, value: opt.value } },
+ checked: { literalBoolean: false },
+ },
+ },
+ });
+ });
+
+ // Add "Other" checkbox for custom input
+ components.push({
+ id: `page-${pageIdx}-checkbox-other`,
+ page: pageIdx,
+ component: {
+ Checkbox: {
+ label: { literalString: 'Other' },
+ description: { literalString: 'Provide a custom answer' },
+ onChange: { actionId: 'toggle', parameters: { questionId: qId, value: '__other__' } },
+ checked: { literalBoolean: false },
+ },
+ },
+ });
+ break;
+ }
+
+ case 'input': {
+ components.push({
+ id: `page-${pageIdx}-input`,
+ page: pageIdx,
+ component: {
+ TextField: {
+ value: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
+ onChange: { actionId: 'input-change', parameters: { questionId: qId } },
+ placeholder: question.placeholder || 'Enter your answer',
+ type: 'text',
+ },
+ },
+ });
+ break;
+ }
+
+ case 'confirm': {
+ // Confirm type gets handled as a single boolean per page
+ // No extra component — the page navigation handles yes/no
+ break;
+ }
+ }
+ }
+
+ return {
+ surfaceUpdate: {
+ surfaceId,
+ components,
+ initialState: {
+ questionId: `multi-${Date.now()}`,
+ questionType: 'multi-question',
+ pages,
+ totalPages: questions.length,
+ },
+ displayMode: 'popup',
+ },
+ pages,
+ };
+}
+
+/**
+ * Execute questions in AskUserQuestion-style format.
+ * Single question: falls back to legacy sequential popup.
+ * Multiple questions: generates a single multi-page surface.
+ */
+async function executeSimpleFormat(
+ questions: SimpleQuestion[],
+ timeout?: number,
+): Promise> {
+ // Single question: use legacy single-popup flow
+ if (questions.length === 1) {
+ const simpleQ = questions[0];
+ const question = normalizeSimpleQuestion(simpleQ);
+ const params = {
+ question,
+ timeout: timeout ?? DEFAULT_TIMEOUT_MS,
+ } satisfies AskQuestionParams;
+
+ const result = await execute(params);
+ if (!result.success || !result.result) {
+ return result;
+ }
+
+ if (result.result.cancelled) {
+ return result;
+ }
+
+ const answersDict: Record = {};
+ if (result.result.answers.length > 0) {
+ const answer = result.result.answers[0];
+ answersDict[simpleQ.header] = answer.value as string | string[];
+ }
+
+ return {
+ success: true,
+ result: {
+ success: true,
+ surfaceId: result.result.surfaceId,
+ cancelled: false,
+ answers: result.result.answers,
+ timestamp: new Date().toISOString(),
+ answersDict,
+ } as AskQuestionResult & { answersDict: Record },
+ };
+ }
+
+ // Multiple questions: single multi-page surface
+ const compositeId = `multi-${Date.now()}`;
+ const surfaceId = `question-${compositeId}`;
+
+ const { surfaceUpdate, pages } = generateMultiQuestionSurface(questions, surfaceId);
+
+ // Create promise for the composite answer
+ const resultPromise = new Promise((resolve, reject) => {
+ const pendingQuestion: PendingQuestion = {
+ id: compositeId,
+ surfaceId,
+ question: {
+ id: compositeId,
+ type: 'input', // placeholder type — multi-question uses custom answer handling
+ title: 'Multi-question',
+ required: false,
+ },
+ timestamp: Date.now(),
+ timeout: timeout ?? DEFAULT_TIMEOUT_MS,
+ resolve,
+ reject,
+ };
+ pendingQuestions.set(compositeId, pendingQuestion);
+
+ // Also register each sub-question's questionId pointing to the same pending entry
+ // so that select/toggle actions on individual questions get tracked
+ for (const page of pages) {
+ // Initialize selection tracking in the websocket handler
+ if (page.type === 'multi-select') {
+ a2uiWebSocketHandler.initMultiSelect(page.questionId);
+ } else if (page.type === 'select') {
+ a2uiWebSocketHandler.initSingleSelect(page.questionId);
+ }
+ }
+
+ setTimeout(() => {
+ if (pendingQuestions.has(compositeId)) {
+ pendingQuestions.delete(compositeId);
+ resolve({
+ success: false,
+ surfaceId,
+ cancelled: false,
+ answers: [],
+ timestamp: new Date().toISOString(),
+ error: 'Question timed out',
+ });
+ }
+ }, timeout ?? DEFAULT_TIMEOUT_MS);
+ });
+
+ // Send the surface
+ const sentCount = a2uiWebSocketHandler.sendSurface(surfaceUpdate);
+
+ // If no local WS clients, start HTTP polling for answer from Dashboard
+ if (sentCount === 0) {
+ startAnswerPolling(compositeId, true);
+ }
+
+ // Wait for answer
+ const result = await resultPromise;
+
+ // If cancelled, return as-is
+ if (result.cancelled) {
+ return { success: true, result };
+ }
+
+ // Build answersDict from the answers array
+ const answersDict: Record = {};
+ if (result.answers) {
+ for (const answer of result.answers) {
+ // Find the matching SimpleQuestion by questionId (which maps to header)
+ const simpleQ = questions.find(q => q.header === answer.questionId);
+ if (simpleQ) {
+ answersDict[simpleQ.header] = answer.value as string | string[];
+ }
+ }
+ }
+
+ return {
+ success: true,
+ result: {
+ ...result,
+ answersDict,
+ } as AskQuestionResult & { answersDict: Record },
+ };
+}