mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: add Sheet component for bottom sheet UI with drag-to-dismiss and snap points
test: implement DialogStyleContext tests for preference management and style recommendations test: create tests for useAutoSelection hook, including countdown and pause functionality feat: implement useAutoSelection hook for enhanced auto-selection with sound notifications feat: create Zustand store for managing issue submission wizard state feat: add Zod validation schemas for issue-related API requests feat: implement issue service for CRUD operations and validation handling feat: define TypeScript types for issue submission and management
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
CliToolConfig,
|
||||
ApiEndpoints,
|
||||
UserPreferences,
|
||||
A2UIPreferences,
|
||||
} from '../types/store';
|
||||
|
||||
// Default CLI tools configuration
|
||||
@@ -75,12 +76,25 @@ const defaultUserPreferences: UserPreferences = {
|
||||
defaultSortDirection: 'desc',
|
||||
};
|
||||
|
||||
// Default A2UI preferences
|
||||
const defaultA2uiPreferences: A2UIPreferences = {
|
||||
dialogStyle: 'modal',
|
||||
smartModeEnabled: true,
|
||||
autoSelectionDuration: 30,
|
||||
autoSelectionSoundEnabled: false,
|
||||
pauseOnInteraction: true,
|
||||
showA2UIButtonInToolbar: true,
|
||||
drawerSide: 'right',
|
||||
drawerSize: 'md',
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState: ConfigState = {
|
||||
cliTools: defaultCliTools,
|
||||
defaultCliTool: 'gemini',
|
||||
apiEndpoints: defaultApiEndpoints,
|
||||
userPreferences: defaultUserPreferences,
|
||||
a2uiPreferences: defaultA2uiPreferences,
|
||||
featureFlags: {
|
||||
orchestratorEnabled: true,
|
||||
darkModeEnabled: true,
|
||||
@@ -158,6 +172,16 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
set({ userPreferences: defaultUserPreferences }, false, 'resetUserPreferences');
|
||||
},
|
||||
|
||||
// ========== A2UI Preferences Actions ==========
|
||||
|
||||
setA2uiPreferences: (prefs: A2UIPreferences) => {
|
||||
set({ a2uiPreferences: prefs }, false, 'setA2uiPreferences');
|
||||
},
|
||||
|
||||
resetA2uiPreferences: () => {
|
||||
set({ a2uiPreferences: defaultA2uiPreferences }, false, 'resetA2uiPreferences');
|
||||
},
|
||||
|
||||
// ========== Feature Flags Actions ==========
|
||||
|
||||
setFeatureFlag: (flag: string, enabled: boolean) => {
|
||||
|
||||
@@ -298,3 +298,15 @@ export type {
|
||||
IssueQueueIntegrationActions,
|
||||
IssueQueueIntegrationStore,
|
||||
} from '../types/terminal-dashboard';
|
||||
|
||||
// Issue Dialog Store
|
||||
export {
|
||||
useIssueDialogStore,
|
||||
} from './issueDialogStore';
|
||||
|
||||
export type {
|
||||
IssueType,
|
||||
IssuePriority,
|
||||
IssueFormData,
|
||||
IssueDialogState,
|
||||
} from './issueDialogStore';
|
||||
|
||||
279
ccw/frontend/src/stores/issueDialogStore.ts
Normal file
279
ccw/frontend/src/stores/issueDialogStore.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// ========================================
|
||||
// Issue Dialog Store
|
||||
// ========================================
|
||||
// Zustand store for managing issue submission wizard state
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type IssueType = 'bug' | 'feature' | 'improvement' | 'other';
|
||||
export type IssuePriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
export interface IssueFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
type: IssueType;
|
||||
priority: IssuePriority;
|
||||
tags: string[];
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
export interface WizardStep {
|
||||
id: string;
|
||||
field: keyof IssueFormData | 'summary';
|
||||
title: string;
|
||||
description?: string;
|
||||
isOptional?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueDialogState {
|
||||
// Dialog state
|
||||
isOpen: boolean;
|
||||
mode: 'wizard' | 'quick' | 'cli';
|
||||
|
||||
// Wizard state
|
||||
currentStep: number;
|
||||
steps: WizardStep[];
|
||||
|
||||
// Form data
|
||||
formData: IssueFormData;
|
||||
validationErrors: Partial<Record<keyof IssueFormData, string>>;
|
||||
|
||||
// Submission state
|
||||
isSubmitting: boolean;
|
||||
submitError: string | null;
|
||||
submittedIssueId: string | null;
|
||||
|
||||
// Actions - Dialog
|
||||
openDialog: (mode?: 'wizard' | 'quick' | 'cli') => void;
|
||||
closeDialog: () => void;
|
||||
|
||||
// Actions - Wizard navigation
|
||||
goToStep: (step: number) => void;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
|
||||
// Actions - Form
|
||||
updateField: <K extends keyof IssueFormData>(field: K, value: IssueFormData[K]) => void;
|
||||
setFormData: (data: Partial<IssueFormData>) => void;
|
||||
resetForm: () => void;
|
||||
validateCurrentStep: () => boolean;
|
||||
|
||||
// Actions - Submission
|
||||
submitIssue: () => Promise<{ success: boolean; issueId?: string; error?: string }>;
|
||||
}
|
||||
|
||||
// ========== Default Values ==========
|
||||
|
||||
const defaultFormData: IssueFormData = {
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'other',
|
||||
priority: 'medium',
|
||||
tags: [],
|
||||
project_id: undefined,
|
||||
};
|
||||
|
||||
const defaultSteps: WizardStep[] = [
|
||||
{
|
||||
id: 'title',
|
||||
field: 'title',
|
||||
title: 'Issue 标题',
|
||||
description: '请输入一个简洁明确的标题',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
field: 'description',
|
||||
title: 'Issue 描述',
|
||||
description: '请详细描述问题或需求',
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
field: 'type',
|
||||
title: 'Issue 类型',
|
||||
description: '选择 Issue 的类型',
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
field: 'priority',
|
||||
title: '优先级',
|
||||
description: '设置 Issue 的处理优先级',
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
field: 'summary',
|
||||
title: '确认提交',
|
||||
description: '请确认以下信息后提交',
|
||||
},
|
||||
];
|
||||
|
||||
// ========== Store Implementation ==========
|
||||
|
||||
export const useIssueDialogStore = create<IssueDialogState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
isOpen: false,
|
||||
mode: 'wizard',
|
||||
currentStep: 0,
|
||||
steps: defaultSteps,
|
||||
formData: { ...defaultFormData },
|
||||
validationErrors: {},
|
||||
isSubmitting: false,
|
||||
submitError: null,
|
||||
submittedIssueId: null,
|
||||
|
||||
// Dialog actions
|
||||
openDialog: (mode = 'wizard') => {
|
||||
set({
|
||||
isOpen: true,
|
||||
mode,
|
||||
currentStep: 0,
|
||||
formData: { ...defaultFormData },
|
||||
validationErrors: {},
|
||||
submitError: null,
|
||||
submittedIssueId: null,
|
||||
});
|
||||
},
|
||||
|
||||
closeDialog: () => {
|
||||
set({
|
||||
isOpen: false,
|
||||
isSubmitting: false,
|
||||
});
|
||||
},
|
||||
|
||||
// Wizard navigation
|
||||
goToStep: (step) => {
|
||||
const { steps, validateCurrentStep } = get();
|
||||
if (step >= 0 && step < steps.length) {
|
||||
// Validate current step before moving forward
|
||||
if (step > get().currentStep && !validateCurrentStep()) {
|
||||
return;
|
||||
}
|
||||
set({ currentStep: step });
|
||||
}
|
||||
},
|
||||
|
||||
nextStep: () => {
|
||||
const { currentStep, steps, validateCurrentStep } = get();
|
||||
if (currentStep < steps.length - 1) {
|
||||
if (validateCurrentStep()) {
|
||||
set({ currentStep: currentStep + 1 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
prevStep: () => {
|
||||
const { currentStep } = get();
|
||||
if (currentStep > 0) {
|
||||
set({ currentStep: currentStep - 1 });
|
||||
}
|
||||
},
|
||||
|
||||
// Form actions
|
||||
updateField: (field, value) => {
|
||||
set((state) => ({
|
||||
formData: { ...state.formData, [field]: value },
|
||||
validationErrors: { ...state.validationErrors, [field]: undefined },
|
||||
}));
|
||||
},
|
||||
|
||||
setFormData: (data) => {
|
||||
set((state) => ({
|
||||
formData: { ...state.formData, ...data },
|
||||
}));
|
||||
},
|
||||
|
||||
resetForm: () => {
|
||||
set({
|
||||
formData: { ...defaultFormData },
|
||||
validationErrors: {},
|
||||
currentStep: 0,
|
||||
submitError: null,
|
||||
submittedIssueId: null,
|
||||
});
|
||||
},
|
||||
|
||||
validateCurrentStep: () => {
|
||||
const { currentStep, steps, formData } = get();
|
||||
const currentField = steps[currentStep]?.field;
|
||||
|
||||
if (currentField === 'summary') {
|
||||
return true; // Summary step doesn't need validation
|
||||
}
|
||||
|
||||
const errors: Partial<Record<keyof IssueFormData, string>> = {};
|
||||
|
||||
if (currentField === 'title') {
|
||||
if (!formData.title.trim()) {
|
||||
errors.title = '标题不能为空';
|
||||
} else if (formData.title.length > 200) {
|
||||
errors.title = '标题不能超过200个字符';
|
||||
}
|
||||
}
|
||||
|
||||
if (currentField === 'description') {
|
||||
if (!formData.description.trim()) {
|
||||
errors.description = '描述不能为空';
|
||||
} else if (formData.description.length > 10000) {
|
||||
errors.description = '描述不能超过10000个字符';
|
||||
}
|
||||
}
|
||||
|
||||
set({ validationErrors: errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
},
|
||||
|
||||
// Submission
|
||||
submitIssue: async () => {
|
||||
const { formData } = get();
|
||||
set({ isSubmitting: true, submitError: null });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/issues', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: `ISSUE-${Date.now()}`,
|
||||
title: formData.title,
|
||||
context: formData.description,
|
||||
priority: formData.priority === 'urgent' ? 1 :
|
||||
formData.priority === 'high' ? 2 :
|
||||
formData.priority === 'medium' ? 3 : 4,
|
||||
tags: formData.tags,
|
||||
status: 'registered',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || result.error) {
|
||||
set({
|
||||
isSubmitting: false,
|
||||
submitError: result.error || '提交失败,请稍后重试'
|
||||
});
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
set({
|
||||
isSubmitting: false,
|
||||
submittedIssueId: result.issue?.id
|
||||
});
|
||||
|
||||
return { success: true, issueId: result.issue?.id };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '网络错误,请稍后重试';
|
||||
set({ isSubmitting: false, submitError: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'issue-dialog-store' }
|
||||
)
|
||||
);
|
||||
|
||||
export default useIssueDialogStore;
|
||||
@@ -22,6 +22,10 @@ export interface TerminalPaneState {
|
||||
id: PaneId;
|
||||
/** Bound terminal session key (null = empty pane awaiting assignment) */
|
||||
sessionId: string | null;
|
||||
/** Display mode: 'terminal' for terminal output, 'file' for file preview */
|
||||
displayMode: 'terminal' | 'file';
|
||||
/** File path for file preview mode (null when in terminal mode) */
|
||||
filePath: string | null;
|
||||
}
|
||||
|
||||
export interface TerminalGridState {
|
||||
@@ -45,6 +49,12 @@ export interface TerminalGridActions {
|
||||
config: CreateCliSessionInput,
|
||||
projectPath: string | null
|
||||
) => Promise<{ paneId: PaneId; session: CliSession } | null>;
|
||||
/** Set pane display mode (terminal or file preview) */
|
||||
setPaneDisplayMode: (paneId: PaneId, mode: 'terminal' | 'file') => void;
|
||||
/** Set file path for file preview mode */
|
||||
setPaneFilePath: (paneId: PaneId, filePath: string | null) => void;
|
||||
/** Show file in pane (combines setPaneDisplayMode and setPaneFilePath) */
|
||||
showFileInPane: (paneId: PaneId, filePath: string) => void;
|
||||
}
|
||||
|
||||
export type TerminalGridStore = TerminalGridState & TerminalGridActions;
|
||||
@@ -52,7 +62,50 @@ export type TerminalGridStore = TerminalGridState & TerminalGridActions;
|
||||
// ========== Constants ==========
|
||||
|
||||
const GRID_STORAGE_KEY = 'terminal-grid-storage';
|
||||
const GRID_STORAGE_VERSION = 1;
|
||||
const GRID_STORAGE_VERSION = 2;
|
||||
|
||||
// ========== Migration ==========
|
||||
|
||||
interface LegacyPaneState {
|
||||
id: PaneId;
|
||||
sessionId: string | null;
|
||||
displayMode?: 'terminal' | 'file';
|
||||
filePath?: string | null;
|
||||
}
|
||||
|
||||
interface LegacyState {
|
||||
layout: AllotmentLayoutGroup;
|
||||
panes: Record<PaneId, LegacyPaneState>;
|
||||
focusedPaneId: PaneId | null;
|
||||
nextPaneIdCounter: number;
|
||||
}
|
||||
|
||||
function migratePaneState(pane: LegacyPaneState): TerminalPaneState {
|
||||
return {
|
||||
id: pane.id,
|
||||
sessionId: pane.sessionId,
|
||||
displayMode: pane.displayMode ?? 'terminal',
|
||||
filePath: pane.filePath ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateState(persisted: unknown, version: number): TerminalGridState {
|
||||
if (version < 2) {
|
||||
// Migration from v1 to v2: add displayMode and filePath to panes
|
||||
const legacy = persisted as LegacyState;
|
||||
const migratedPanes: Record<PaneId, TerminalPaneState> = {};
|
||||
for (const [paneId, pane] of Object.entries(legacy.panes)) {
|
||||
migratedPanes[paneId as PaneId] = migratePaneState(pane);
|
||||
}
|
||||
return {
|
||||
layout: legacy.layout,
|
||||
panes: migratedPanes,
|
||||
focusedPaneId: legacy.focusedPaneId,
|
||||
nextPaneIdCounter: legacy.nextPaneIdCounter,
|
||||
};
|
||||
}
|
||||
return persisted as TerminalGridState;
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
@@ -64,7 +117,7 @@ function createInitialLayout(): { layout: AllotmentLayoutGroup; panes: Record<Pa
|
||||
const paneId = generatePaneId(1);
|
||||
return {
|
||||
layout: { direction: 'horizontal', sizes: [100], children: [paneId] },
|
||||
panes: { [paneId]: { id: paneId, sessionId: null } },
|
||||
panes: { [paneId]: { id: paneId, sessionId: null, displayMode: 'terminal', filePath: null } },
|
||||
focusedPaneId: paneId,
|
||||
nextPaneIdCounter: 2,
|
||||
};
|
||||
@@ -109,7 +162,7 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
layout: newLayout,
|
||||
panes: {
|
||||
...state.panes,
|
||||
[newPaneId]: { id: newPaneId, sessionId: null },
|
||||
[newPaneId]: { id: newPaneId, sessionId: null, displayMode: 'terminal', filePath: null },
|
||||
},
|
||||
focusedPaneId: newPaneId,
|
||||
nextPaneIdCounter: state.nextPaneIdCounter + 1,
|
||||
@@ -175,7 +228,7 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
|
||||
const createPane = (): TerminalPaneState => {
|
||||
const id = generatePaneId(counter++);
|
||||
return { id, sessionId: null };
|
||||
return { id, sessionId: null, displayMode: 'terminal', filePath: null };
|
||||
};
|
||||
|
||||
let layout: AllotmentLayoutGroup;
|
||||
@@ -278,7 +331,7 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
layout: newLayout,
|
||||
panes: {
|
||||
...state.panes,
|
||||
[newPaneId]: { id: newPaneId, sessionId: session.sessionKey },
|
||||
[newPaneId]: { id: newPaneId, sessionId: session.sessionKey, displayMode: 'terminal', filePath: null },
|
||||
},
|
||||
focusedPaneId: newPaneId,
|
||||
nextPaneIdCounter: state.nextPaneIdCounter + 1,
|
||||
@@ -293,12 +346,65 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setPaneDisplayMode: (paneId, mode) => {
|
||||
const state = get();
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return;
|
||||
|
||||
set(
|
||||
{
|
||||
panes: {
|
||||
...state.panes,
|
||||
[paneId]: { ...pane, displayMode: mode, filePath: mode === 'terminal' ? null : pane.filePath },
|
||||
},
|
||||
},
|
||||
false,
|
||||
'terminalGrid/setPaneDisplayMode'
|
||||
);
|
||||
},
|
||||
|
||||
setPaneFilePath: (paneId, filePath) => {
|
||||
const state = get();
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return;
|
||||
|
||||
set(
|
||||
{
|
||||
panes: {
|
||||
...state.panes,
|
||||
[paneId]: { ...pane, filePath },
|
||||
},
|
||||
},
|
||||
false,
|
||||
'terminalGrid/setPaneFilePath'
|
||||
);
|
||||
},
|
||||
|
||||
showFileInPane: (paneId, filePath) => {
|
||||
const state = get();
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return;
|
||||
|
||||
set(
|
||||
{
|
||||
panes: {
|
||||
...state.panes,
|
||||
[paneId]: { ...pane, displayMode: 'file', filePath },
|
||||
},
|
||||
focusedPaneId: paneId,
|
||||
},
|
||||
false,
|
||||
'terminalGrid/showFileInPane'
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ name: 'TerminalGridStore' }
|
||||
),
|
||||
{
|
||||
name: GRID_STORAGE_KEY,
|
||||
version: GRID_STORAGE_VERSION,
|
||||
migrate: migrateState,
|
||||
partialize: (state) => ({
|
||||
layout: state.layout,
|
||||
panes: state.panes,
|
||||
|
||||
Reference in New Issue
Block a user