From a0764e39d1d99fcb4baea635adfeef3f795f1be3 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 1 Mar 2026 19:41:19 +0200 Subject: [PATCH] feat: enhance team name validation and UI interactions - Updated TEAM_NAME_PATTERN to enforce kebab-case naming conventions with lowercase letters and hyphens. - Introduced a sanitizeTeamName function to ensure team names are properly formatted before submission. - Enhanced CreateTeamDialog to provide real-time feedback on team name validity and display sanitized names. - Modified NewProjectCard to open the teams tab upon navigating to a matching worktree. - Updated TeamListView header to clarify the selection process for teams. --- src/main/ipc/guards.ts | 2 +- .../components/dashboard/DashboardView.tsx | 6 +- src/renderer/components/team/TeamListView.tsx | 2 +- .../team/dialogs/CreateTeamDialog.tsx | 48 +++++++++--- .../team/editor/QuickOpenDialog.tsx | 2 +- .../team/review/ChangeReviewDialog.tsx | 70 +++++++++++++----- .../components/team/review/portionCollapse.ts | 74 +++++++++++++++---- src/renderer/hooks/useDiffNavigation.ts | 17 +++-- .../store/slices/changeReviewSlice.ts | 27 +++++-- src/renderer/utils/unwrapIpc.ts | 9 ++- 10 files changed, 200 insertions(+), 57 deletions(-) diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts index 26f4aa74..9829be8a 100644 --- a/src/main/ipc/guards.ts +++ b/src/main/ipc/guards.ts @@ -12,7 +12,7 @@ const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; -const TEAM_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const TASK_ID_PATTERN = /^\d{1,10}$/; const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 3213440e..0469cc5b 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -371,10 +371,11 @@ function findMatchingWorktree( } const NewProjectCard = (): React.JSX.Element => { - const { repositoryGroups, fetchRepositoryGroups } = useStore( + const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore( useShallow((s) => ({ repositoryGroups: s.repositoryGroups, fetchRepositoryGroups: s.fetchRepositoryGroups, + openTeamsTab: s.openTeamsTab, })) ); @@ -396,6 +397,7 @@ const NewProjectCard = (): React.JSX.Element => { const match = findMatchingWorktree(repositoryGroups, selectedPath); if (match) { navigateToMatch(match); + openTeamsTab(); return; } @@ -405,6 +407,7 @@ const NewProjectCard = (): React.JSX.Element => { const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath); if (matchAfterRefresh) { navigateToMatch(matchAfterRefresh); + openTeamsTab(); return; } @@ -437,6 +440,7 @@ const NewProjectCard = (): React.JSX.Element => { repositoryGroups: [syntheticGroup, ...state.repositoryGroups], })); navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); + openTeamsTab(); } catch (error) { logger.error('Error selecting folder:', error); } diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 0ebb1ba0..b473646f 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -541,7 +541,7 @@ export const TeamListView = (): React.JSX.Element => { const renderHeader = (): React.JSX.Element => (
-

Teams

+

Select Team

diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index dc589ae4..e2399f9c 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -5,7 +5,7 @@ * Loads ALL project files via backend API on mount (not limited to expanded dirs). */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { Command } from 'cmdk'; diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index e9ac3484..ec12f5e8 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { undo } from '@codemirror/commands'; -import { goToNextChunk, rejectChunk } from '@codemirror/merge'; +import { rejectChunk } from '@codemirror/merge'; import { isElectronMode } from '@renderer/api'; import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu'; import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav'; -import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNavigation'; +import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation'; import { useViewedFiles } from '@renderer/hooks/useViewedFiles'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; @@ -69,6 +69,7 @@ export const ChangeReviewDialog = ({ applying, applyError, setHunkDecision, + clearHunkDecisionByOriginalIndex, setCollapseUnchanged, fetchFileContent, acceptAllFile, @@ -108,6 +109,11 @@ export const ChangeReviewDialog = ({ const scrollContainerRef = useRef(null); // Last focused CM editor — for Cmd+Z outside editor const lastFocusedEditorRef = useRef(null); + // Timestamp of last bulk accept/reject-all operation (for Ctrl/Cmd+Z UX) + const lastBulkActionAtRef = useRef(0); + // Track recent per-hunk actions so Ctrl/Cmd+Z can clear persisted decisions (reopen-safe) + const lastHunkActionAtRef = useRef>({}); + const hunkDecisionUndoStackRef = useRef>({}); // Proxy ref for useDiffNavigation (points to active file's editor) const activeEditorViewRef = useRef(null); @@ -171,6 +177,7 @@ export const ChangeReviewDialog = ({ const handleAcceptAll = useCallback(() => { if (!activeChangeSet) return; pushReviewUndoSnapshot(); + lastBulkActionAtRef.current = Date.now(); for (const file of activeChangeSet.files) { acceptAllFile(file.filePath); } @@ -184,6 +191,7 @@ export const ChangeReviewDialog = ({ const handleRejectAll = useCallback(() => { if (!activeChangeSet) return; pushReviewUndoSnapshot(); + lastBulkActionAtRef.current = Date.now(); for (const file of activeChangeSet.files) { rejectAllFile(file.filePath); } @@ -197,14 +205,18 @@ export const ChangeReviewDialog = ({ // Per-file callbacks for ContinuousScrollView const handleHunkAccepted = useCallback( (filePath: string, hunkIndex: number) => { - setHunkDecision(filePath, hunkIndex, 'accepted'); + const originalIndex = setHunkDecision(filePath, hunkIndex, 'accepted'); + lastHunkActionAtRef.current[filePath] = Date.now(); + (hunkDecisionUndoStackRef.current[filePath] ??= []).push(originalIndex); }, [setHunkDecision] ); const handleHunkRejected = useCallback( (filePath: string, hunkIndex: number) => { - setHunkDecision(filePath, hunkIndex, 'rejected'); + const originalIndex = setHunkDecision(filePath, hunkIndex, 'rejected'); + lastHunkActionAtRef.current[filePath] = Date.now(); + (hunkDecisionUndoStackRef.current[filePath] ??= []).push(originalIndex); if (REVIEW_INSTANT_APPLY) { void applySingleFileDecision(teamName, filePath, taskId, memberName); } @@ -422,7 +434,8 @@ export const ChangeReviewDialog = ({ }); }, [activeChangeSet, initialFilePath, scrollToFile]); - // Clear selection state on close + cleanup timer + // Clear selection state on close + cleanup timer. + // setState here is intentional: reset transient UI state when dialog closes. useEffect(() => { if (!open) { setSelectionInfo(null); @@ -469,32 +482,55 @@ export const ChangeReviewDialog = ({ if (!open) return; const handler = (e: KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ' && !e.shiftKey) { - // Don't intercept if focus is inside a CM editor — let CM handle its own undo - if (document.activeElement?.closest('.cm-editor')) return; // Don't intercept native undo in input/textarea const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; - // Try to undo in the last focused CM editor + // Prefer bulk undo (Accept All / Reject All) shortly after bulk action, + // even if focus is inside a CM editor (focus often remains there after clicking buttons). + const now = Date.now(); + const bulkRecently = now - lastBulkActionAtRef.current < 10_000; + if (bulkRecently && useStore.getState().reviewUndoStack.length > 0) { + e.preventDefault(); + e.stopPropagation(); + handleUndoBulk(); + return; + } + + // If the last action was a hunk keep/undo (accept/reject) and we're undoing immediately, + // we must also clear the persisted decision. Otherwise reopening the dialog will replay it. + if (document.activeElement?.closest('.cm-editor')) { + const lastView = lastFocusedEditorRef.current; + const fp = activeFilePathRef.current; + const stack = fp ? hunkDecisionUndoStackRef.current[fp] : undefined; + const lastAt = fp ? (lastHunkActionAtRef.current[fp] ?? 0) : 0; + const hunkRecently = fp ? now - lastAt < 5_000 : false; + + if (fp && stack && stack.length > 0 && hunkRecently && lastView?.dom.isConnected) { + e.preventDefault(); + e.stopPropagation(); + undo(lastView); + const originalIndex = stack.pop()!; + clearHunkDecisionByOriginalIndex(fp, originalIndex); + return; + } + + // Otherwise, let CM handle its own undo + return; + } + + // Otherwise try to undo in the last focused CM editor const lastView = lastFocusedEditorRef.current; if (lastView?.dom.isConnected) { e.preventDefault(); e.stopPropagation(); undo(lastView); - return; - } - - // Fall back to bulk undo (Accept All / Reject All) - if (useStore.getState().reviewUndoStack.length > 0) { - e.preventDefault(); - e.stopPropagation(); - handleUndoBulk(); } } }; document.addEventListener('keydown', handler, true); return () => document.removeEventListener('keydown', handler, true); - }, [open, handleUndoBulk]); + }, [open, handleUndoBulk, clearHunkDecisionByOriginalIndex]); // Cmd+N IPC listener (forwarded from main process) useEffect(() => { diff --git a/src/renderer/components/team/review/portionCollapse.ts b/src/renderer/components/team/review/portionCollapse.ts index a9dc4976..ec29a460 100644 --- a/src/renderer/components/team/review/portionCollapse.ts +++ b/src/renderer/components/team/review/portionCollapse.ts @@ -281,29 +281,53 @@ const portionCollapseTheme = EditorView.theme({ // CM recognizes it's the same field → keeps accumulated state (expanded regions) → no create() call. // Config is read from portionCollapseConfigFacet (controlled by compartment). -const portionCollapseField = StateField.define({ - create(state: EditorState): DecorationSet { +interface PortionCollapseState { + decorations: DecorationSet; + /** + * Once the user expands everything (i.e. removes the last collapse widget), + * we should NOT re-initialize collapse on harmless transactions like cursor moves. + */ + userExpandedAll: boolean; +} + +const portionCollapseField = StateField.define({ + create(state: EditorState): PortionCollapseState { const cfg = state.facet(portionCollapseConfigFacet); - return buildPortionRanges(state, cfg.margin, cfg.minSize, cfg.portionSize); + return { + decorations: buildPortionRanges(state, cfg.margin, cfg.minSize, cfg.portionSize), + userExpandedAll: false, + }; }, - update(deco: DecorationSet, tr: Transaction): DecorationSet { + update(value: PortionCollapseState, tr: Transaction): PortionCollapseState { const cfg = tr.state.facet(portionCollapseConfigFacet); // 1. Expand effects - let result = deco; + let nextDeco = value.decorations; + let userExpandedAll = value.userExpandedAll; let hasExpandEffect = false; for (const effect of tr.effects) { if (effect.is(expandPortion)) { hasExpandEffect = true; - result = handleExpandPortion(result, effect.value, tr.state, cfg.minSize, cfg.portionSize); + nextDeco = handleExpandPortion( + nextDeco, + effect.value, + tr.state, + cfg.minSize, + cfg.portionSize + ); } if (effect.is(expandAllAtPos)) { hasExpandEffect = true; - result = handleExpandAll(result, effect.value); + nextDeco = handleExpandAll(nextDeco, effect.value); } } - if (hasExpandEffect) return result; + if (hasExpandEffect) { + if (nextDeco === Decoration.none) { + userExpandedAll = true; + } + return { decorations: nextDeco, userExpandedAll }; + } // 2. Accept chunk (updateOriginalDoc) — editor doc unchanged, keep decorations. // Full rebuild here would destroy user's expanded state (Expand All / Expand N). @@ -311,27 +335,47 @@ const portionCollapseField = StateField.define({ // When mirrorEditsAfterResolve adds updateOriginalDoc to a docChanged transaction, // we must NOT short-circuit — fall through to docChanged handler for proper rebuild. if (tr.effects.some((e) => e.is(updateOriginalDoc)) && !tr.docChanged) { - return deco; + return value; } - // 3. Document changed (reject, user edit) → full rebuild + // 3. Document changed (reject, user edit) + // + // Rebuilding from scratch here causes a bad UX: after the user expands (or when a hunk is + // applied) the editor can suddenly re-collapse unchanged regions, hiding the code the user + // was actively looking at. Instead, keep the user's current collapsed/expanded state stable + // by mapping existing decorations through the document changes. if (tr.docChanged) { - return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize); + const mapped = value.decorations.map(tr.changes); + // If we previously had no collapse decoration but chunks are now available, initialize once. + // BUT: if the user explicitly expanded everything, never re-collapse automatically. + if (!value.userExpandedAll && value.decorations === Decoration.none) { + const chunks = getChunks(tr.state); + if (chunks) { + return { + decorations: buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize), + userExpandedAll: false, + }; + } + } + return { decorations: mapped, userExpandedAll: value.userExpandedAll }; } // 4. Lazy init - if (deco === Decoration.none) { + if (!value.userExpandedAll && value.decorations === Decoration.none) { const chunks = getChunks(tr.state); if (chunks) { - return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize); + return { + decorations: buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize), + userExpandedAll: false, + }; } } - return deco; + return value; }, provide(f) { - return EditorView.decorations.from(f); + return EditorView.decorations.from(f, (v) => v.decorations); }, }); diff --git a/src/renderer/hooks/useDiffNavigation.ts b/src/renderer/hooks/useDiffNavigation.ts index 59a5cc9b..a761d858 100644 --- a/src/renderer/hooks/useDiffNavigation.ts +++ b/src/renderer/hooks/useDiffNavigation.ts @@ -264,14 +264,18 @@ export function useDiffNavigation( } }, [selectedFilePath, currentHunkIndex, onHunkRejected]); - // Store refs for stable closure + // Store refs for stable closure (avoids re-registering keydown on every render) const onCloseRef = useRef(onClose); const onSaveFileRef = useRef(onSaveFile); + const onHunkAcceptedRef = useRef(onHunkAccepted); + const selectedFilePathRef = useRef(selectedFilePath); useEffect(() => { onCloseRef.current = onClose; onSaveFileRef.current = onSaveFile; - }, [onClose, onSaveFile]); + onHunkAcceptedRef.current = onHunkAccepted; + selectedFilePathRef.current = selectedFilePath; + }, [onClose, onSaveFile, onHunkAccepted, selectedFilePath]); // Keyboard handler useEffect(() => { @@ -327,11 +331,14 @@ export function useDiffNavigation( event.preventDefault(); const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current); if (view) { - const filePath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current); - if (filePath && onHunkAccepted) { + const filePath = getActiveFilePath( + selectedFilePathRef.current, + continuousOptionsRef.current + ); + if (filePath && onHunkAcceptedRef.current) { const cursorPos = view.state.selection.main.head; const idx = computeChunkIndexAtPos(view.state, cursorPos); - onHunkAccepted(filePath, idx); + onHunkAcceptedRef.current(filePath, idx); } acceptChunk(view); requestAnimationFrame(() => goToNextHunk()); diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 68d6baf2..c854f9e4 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -94,7 +94,13 @@ export interface ChangeReviewSlice { clearDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise; // Phase 2 actions - setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => void; + /** + * Set decision for a hunk at the current (visible) CM index. + * Returns the stable/original hunk index used as the decision key. + */ + setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => number; + /** Clear a persisted decision using the stable/original hunk index */ + clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => void; setFileDecision: (filePath: string, decision: HunkDecision) => void; setFileChunkCount: (filePath: string, count: number) => void; pushReviewUndoSnapshot: () => void; @@ -429,6 +435,17 @@ export const createChangeReviewSlice: StateCreator ({ hunkDecisions: { ...s.hunkDecisions, [key]: decision }, })); + return originalIndex; + }, + + clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => { + const key = `${filePath}:${originalIndex}`; + set((s) => { + if (!(key in s.hunkDecisions)) return s; + const next = { ...s.hunkDecisions }; + delete next[key]; + return { hunkDecisions: next }; + }); }, setFileDecision: (filePath: string, decision: HunkDecision) => { @@ -727,14 +744,14 @@ export const createChangeReviewSlice: StateCreator(operation: string, fn: () => Promise): Promise { try { return await fn(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - logger.error(`[${operation}] ${message}`); + if (EXPECTED_IPC_SIGNALS.some((sig) => message.includes(sig))) { + logger.debug(`[${operation}] ${message}`); + } else { + logger.error(`[${operation}] ${message}`); + } throw new IpcError(operation, message, error); } }