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:
catlog22
2026-02-16 11:51:21 +08:00
parent 374a1e1c2c
commit 2202c2ccfd
35 changed files with 3717 additions and 145 deletions

View File

@@ -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) => {

View File

@@ -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';

View 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;

View File

@@ -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,