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.
This commit is contained in:
iliya 2026-03-01 19:41:19 +02:00
parent 47d979a43d
commit a0764e39d1
10 changed files with 200 additions and 57 deletions

View file

@ -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 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 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 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 TASK_ID_PATTERN = /^\d{1,10}$/;
const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; 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}$/; const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;

View file

@ -371,10 +371,11 @@ function findMatchingWorktree(
} }
const NewProjectCard = (): React.JSX.Element => { const NewProjectCard = (): React.JSX.Element => {
const { repositoryGroups, fetchRepositoryGroups } = useStore( const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore(
useShallow((s) => ({ useShallow((s) => ({
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
fetchRepositoryGroups: s.fetchRepositoryGroups, fetchRepositoryGroups: s.fetchRepositoryGroups,
openTeamsTab: s.openTeamsTab,
})) }))
); );
@ -396,6 +397,7 @@ const NewProjectCard = (): React.JSX.Element => {
const match = findMatchingWorktree(repositoryGroups, selectedPath); const match = findMatchingWorktree(repositoryGroups, selectedPath);
if (match) { if (match) {
navigateToMatch(match); navigateToMatch(match);
openTeamsTab();
return; return;
} }
@ -405,6 +407,7 @@ const NewProjectCard = (): React.JSX.Element => {
const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath); const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath);
if (matchAfterRefresh) { if (matchAfterRefresh) {
navigateToMatch(matchAfterRefresh); navigateToMatch(matchAfterRefresh);
openTeamsTab();
return; return;
} }
@ -437,6 +440,7 @@ const NewProjectCard = (): React.JSX.Element => {
repositoryGroups: [syntheticGroup, ...state.repositoryGroups], repositoryGroups: [syntheticGroup, ...state.repositoryGroups],
})); }));
navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); navigateToMatch({ repoId: encodedId, worktreeId: encodedId });
openTeamsTab();
} catch (error) { } catch (error) {
logger.error('Error selecting folder:', error); logger.error('Error selecting folder:', error);
} }

View file

@ -541,7 +541,7 @@ export const TeamListView = (): React.JSX.Element => {
const renderHeader = (): React.JSX.Element => ( const renderHeader = (): React.JSX.Element => (
<div className="mb-4"> <div className="mb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--color-text)]">Teams</h2> <h2 className="text-base font-semibold text-[var(--color-text)]">Select Team</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"

View file

@ -142,15 +142,27 @@ function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
.filter((member): member is NonNullable<typeof member> => member !== null); .filter((member): member is NonNullable<typeof member> => member !== null);
} }
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS /** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */
const TEAM_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; function sanitizeTeamName(name: string): string {
return name
.replace(/[^a-zA-Z0-9]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+/g, '')
.replace(/-+$/g, '')
.toLowerCase();
}
const MEMBER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const MEMBER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
function validateTeamNameInline(name: string): string | null { function validateTeamNameInline(name: string): string | null {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) return null; if (!trimmed) return null;
if (!TEAM_NAME_RE.test(trimmed) || trimmed.length > 64) { const sanitized = sanitizeTeamName(trimmed);
return 'Use kebab-case [a-z0-9-], max 64 chars'; if (!sanitized) {
return 'Name must contain at least one letter or digit';
}
if (sanitized.length > 128) {
return 'Name is too long (max 128 chars)';
} }
return null; return null;
} }
@ -169,11 +181,20 @@ function validateRequest(
options?: { requireCwd?: boolean } options?: { requireCwd?: boolean }
): ValidationResult { ): ValidationResult {
const requireCwd = options?.requireCwd ?? true; const requireCwd = options?.requireCwd ?? true;
if (!TEAM_NAME_RE.test(request.teamName) || request.teamName.length > 64) { const sanitized = sanitizeTeamName(request.teamName);
if (!sanitized) {
return { return {
valid: false, valid: false,
errors: { errors: {
teamName: 'Use kebab-case [a-z0-9-], max 64 chars', teamName: 'Name must contain at least one letter or digit',
},
};
}
if (sanitized.length > 128) {
return {
valid: false,
errors: {
teamName: 'Name is too long (max 128 chars)',
}, },
}; };
} }
@ -519,9 +540,11 @@ export const CreateTeamDialog = ({
const effectiveModel = const effectiveModel =
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined; selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
const request = useMemo<TeamCreateRequest>( const request = useMemo<TeamCreateRequest>(
() => ({ () => ({
teamName: teamName.trim(), teamName: sanitizedTeamName,
description: description.trim() || undefined, description: description.trim() || undefined,
color: teamColor || undefined, color: teamColor || undefined,
members: buildMembers(members), members: buildMembers(members),
@ -529,7 +552,7 @@ export const CreateTeamDialog = ({
prompt: prompt.trim() || undefined, prompt: prompt.trim() || undefined,
model: effectiveModel, model: effectiveModel,
}), }),
[teamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel] [sanitizedTeamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
); );
const activeError = localError ?? provisioningError; const activeError = localError ?? provisioningError;
@ -576,7 +599,7 @@ export const CreateTeamDialog = ({
}; };
const handleSubmit = (): void => { const handleSubmit = (): void => {
if (existingTeamNames.includes(request.teamName)) { if (existingTeamNames.includes(sanitizedTeamName)) {
setFieldErrors({ teamName: 'Team name already exists' }); setFieldErrors({ teamName: 'Team name already exists' });
setLocalError('Check form fields'); setLocalError('Check form fields');
return; return;
@ -710,13 +733,18 @@ export const CreateTeamDialog = ({
onChange={(event) => setTeamName(event.target.value)} onChange={(event) => setTeamName(event.target.value)}
placeholder="team-alpha" placeholder="team-alpha"
/> />
{existingTeamNames.includes(teamName.trim()) ? ( {existingTeamNames.includes(sanitizedTeamName) ? (
<p className="text-[11px] text-red-300">Team name already exists</p> <p className="text-[11px] text-red-300">Team name already exists</p>
) : validateTeamNameInline(teamName) ? ( ) : validateTeamNameInline(teamName) ? (
<p className="text-[11px] text-red-300">{validateTeamNameInline(teamName)}</p> <p className="text-[11px] text-red-300">{validateTeamNameInline(teamName)}</p>
) : fieldErrors.teamName ? ( ) : fieldErrors.teamName ? (
<p className="text-[11px] text-red-300">{fieldErrors.teamName}</p> <p className="text-[11px] text-red-300">{fieldErrors.teamName}</p>
) : null} ) : null}
{sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
On disk: <span className="font-mono">{sanitizedTeamName}</span>
</p>
) : null}
</div> </div>
<div className="space-y-1.5 md:col-span-2"> <div className="space-y-1.5 md:col-span-2">

View file

@ -5,7 +5,7 @@
* Loads ALL project files via backend API on mount (not limited to expanded dirs). * 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 { useStore } from '@renderer/store';
import { Command } from 'cmdk'; import { Command } from 'cmdk';

View file

@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { undo } from '@codemirror/commands'; import { undo } from '@codemirror/commands';
import { goToNextChunk, rejectChunk } from '@codemirror/merge'; import { rejectChunk } from '@codemirror/merge';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu'; import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu';
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav'; 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 { useViewedFiles } from '@renderer/hooks/useViewedFiles';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -69,6 +69,7 @@ export const ChangeReviewDialog = ({
applying, applying,
applyError, applyError,
setHunkDecision, setHunkDecision,
clearHunkDecisionByOriginalIndex,
setCollapseUnchanged, setCollapseUnchanged,
fetchFileContent, fetchFileContent,
acceptAllFile, acceptAllFile,
@ -108,6 +109,11 @@ export const ChangeReviewDialog = ({
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
// Last focused CM editor — for Cmd+Z outside editor // Last focused CM editor — for Cmd+Z outside editor
const lastFocusedEditorRef = useRef<EditorView | null>(null); const lastFocusedEditorRef = useRef<EditorView | null>(null);
// Timestamp of last bulk accept/reject-all operation (for Ctrl/Cmd+Z UX)
const lastBulkActionAtRef = useRef<number>(0);
// Track recent per-hunk actions so Ctrl/Cmd+Z can clear persisted decisions (reopen-safe)
const lastHunkActionAtRef = useRef<Record<string, number>>({});
const hunkDecisionUndoStackRef = useRef<Record<string, number[]>>({});
// Proxy ref for useDiffNavigation (points to active file's editor) // Proxy ref for useDiffNavigation (points to active file's editor)
const activeEditorViewRef = useRef<EditorView | null>(null); const activeEditorViewRef = useRef<EditorView | null>(null);
@ -171,6 +177,7 @@ export const ChangeReviewDialog = ({
const handleAcceptAll = useCallback(() => { const handleAcceptAll = useCallback(() => {
if (!activeChangeSet) return; if (!activeChangeSet) return;
pushReviewUndoSnapshot(); pushReviewUndoSnapshot();
lastBulkActionAtRef.current = Date.now();
for (const file of activeChangeSet.files) { for (const file of activeChangeSet.files) {
acceptAllFile(file.filePath); acceptAllFile(file.filePath);
} }
@ -184,6 +191,7 @@ export const ChangeReviewDialog = ({
const handleRejectAll = useCallback(() => { const handleRejectAll = useCallback(() => {
if (!activeChangeSet) return; if (!activeChangeSet) return;
pushReviewUndoSnapshot(); pushReviewUndoSnapshot();
lastBulkActionAtRef.current = Date.now();
for (const file of activeChangeSet.files) { for (const file of activeChangeSet.files) {
rejectAllFile(file.filePath); rejectAllFile(file.filePath);
} }
@ -197,14 +205,18 @@ export const ChangeReviewDialog = ({
// Per-file callbacks for ContinuousScrollView // Per-file callbacks for ContinuousScrollView
const handleHunkAccepted = useCallback( const handleHunkAccepted = useCallback(
(filePath: string, hunkIndex: number) => { (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] [setHunkDecision]
); );
const handleHunkRejected = useCallback( const handleHunkRejected = useCallback(
(filePath: string, hunkIndex: number) => { (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) { if (REVIEW_INSTANT_APPLY) {
void applySingleFileDecision(teamName, filePath, taskId, memberName); void applySingleFileDecision(teamName, filePath, taskId, memberName);
} }
@ -422,7 +434,8 @@ export const ChangeReviewDialog = ({
}); });
}, [activeChangeSet, initialFilePath, scrollToFile]); }, [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(() => { useEffect(() => {
if (!open) { if (!open) {
setSelectionInfo(null); setSelectionInfo(null);
@ -469,32 +482,55 @@ export const ChangeReviewDialog = ({
if (!open) return; if (!open) return;
const handler = (e: KeyboardEvent): void => { const handler = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ' && !e.shiftKey) { 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 // Don't intercept native undo in input/textarea
const tag = document.activeElement?.tagName; const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return; 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; const lastView = lastFocusedEditorRef.current;
if (lastView?.dom.isConnected) { if (lastView?.dom.isConnected) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
undo(lastView); 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); document.addEventListener('keydown', handler, true);
return () => document.removeEventListener('keydown', handler, true); return () => document.removeEventListener('keydown', handler, true);
}, [open, handleUndoBulk]); }, [open, handleUndoBulk, clearHunkDecisionByOriginalIndex]);
// Cmd+N IPC listener (forwarded from main process) // Cmd+N IPC listener (forwarded from main process)
useEffect(() => { useEffect(() => {

View file

@ -281,29 +281,53 @@ const portionCollapseTheme = EditorView.theme({
// CM recognizes it's the same field → keeps accumulated state (expanded regions) → no create() call. // CM recognizes it's the same field → keeps accumulated state (expanded regions) → no create() call.
// Config is read from portionCollapseConfigFacet (controlled by compartment). // Config is read from portionCollapseConfigFacet (controlled by compartment).
const portionCollapseField = StateField.define<DecorationSet>({ interface PortionCollapseState {
create(state: EditorState): DecorationSet { 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<PortionCollapseState>({
create(state: EditorState): PortionCollapseState {
const cfg = state.facet(portionCollapseConfigFacet); 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); const cfg = tr.state.facet(portionCollapseConfigFacet);
// 1. Expand effects // 1. Expand effects
let result = deco; let nextDeco = value.decorations;
let userExpandedAll = value.userExpandedAll;
let hasExpandEffect = false; let hasExpandEffect = false;
for (const effect of tr.effects) { for (const effect of tr.effects) {
if (effect.is(expandPortion)) { if (effect.is(expandPortion)) {
hasExpandEffect = true; 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)) { if (effect.is(expandAllAtPos)) {
hasExpandEffect = true; 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. // 2. Accept chunk (updateOriginalDoc) — editor doc unchanged, keep decorations.
// Full rebuild here would destroy user's expanded state (Expand All / Expand N). // Full rebuild here would destroy user's expanded state (Expand All / Expand N).
@ -311,27 +335,47 @@ const portionCollapseField = StateField.define<DecorationSet>({
// When mirrorEditsAfterResolve adds updateOriginalDoc to a docChanged transaction, // When mirrorEditsAfterResolve adds updateOriginalDoc to a docChanged transaction,
// we must NOT short-circuit — fall through to docChanged handler for proper rebuild. // we must NOT short-circuit — fall through to docChanged handler for proper rebuild.
if (tr.effects.some((e) => e.is(updateOriginalDoc)) && !tr.docChanged) { 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) { 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 // 4. Lazy init
if (deco === Decoration.none) { if (!value.userExpandedAll && value.decorations === Decoration.none) {
const chunks = getChunks(tr.state); const chunks = getChunks(tr.state);
if (chunks) { 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) { provide(f) {
return EditorView.decorations.from(f); return EditorView.decorations.from(f, (v) => v.decorations);
}, },
}); });

View file

@ -264,14 +264,18 @@ export function useDiffNavigation(
} }
}, [selectedFilePath, currentHunkIndex, onHunkRejected]); }, [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 onCloseRef = useRef(onClose);
const onSaveFileRef = useRef(onSaveFile); const onSaveFileRef = useRef(onSaveFile);
const onHunkAcceptedRef = useRef(onHunkAccepted);
const selectedFilePathRef = useRef(selectedFilePath);
useEffect(() => { useEffect(() => {
onCloseRef.current = onClose; onCloseRef.current = onClose;
onSaveFileRef.current = onSaveFile; onSaveFileRef.current = onSaveFile;
}, [onClose, onSaveFile]); onHunkAcceptedRef.current = onHunkAccepted;
selectedFilePathRef.current = selectedFilePath;
}, [onClose, onSaveFile, onHunkAccepted, selectedFilePath]);
// Keyboard handler // Keyboard handler
useEffect(() => { useEffect(() => {
@ -327,11 +331,14 @@ export function useDiffNavigation(
event.preventDefault(); event.preventDefault();
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current); const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
if (view) { if (view) {
const filePath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current); const filePath = getActiveFilePath(
if (filePath && onHunkAccepted) { selectedFilePathRef.current,
continuousOptionsRef.current
);
if (filePath && onHunkAcceptedRef.current) {
const cursorPos = view.state.selection.main.head; const cursorPos = view.state.selection.main.head;
const idx = computeChunkIndexAtPos(view.state, cursorPos); const idx = computeChunkIndexAtPos(view.state, cursorPos);
onHunkAccepted(filePath, idx); onHunkAcceptedRef.current(filePath, idx);
} }
acceptChunk(view); acceptChunk(view);
requestAnimationFrame(() => goToNextHunk()); requestAnimationFrame(() => goToNextHunk());

View file

@ -94,7 +94,13 @@ export interface ChangeReviewSlice {
clearDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise<void>; clearDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise<void>;
// Phase 2 actions // 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; setFileDecision: (filePath: string, decision: HunkDecision) => void;
setFileChunkCount: (filePath: string, count: number) => void; setFileChunkCount: (filePath: string, count: number) => void;
pushReviewUndoSnapshot: () => void; pushReviewUndoSnapshot: () => void;
@ -429,6 +435,17 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
set((s) => ({ set((s) => ({
hunkDecisions: { ...s.hunkDecisions, [key]: decision }, 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) => { setFileDecision: (filePath: string, decision: HunkDecision) => {
@ -727,14 +744,14 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
try { try {
const content = fileContents[filePath]; const content = fileContents[filePath];
const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts); const innerBaseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions); const innerMaxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions);
const hunkContextHashes = const hunkContextHashes =
maxIdx < baseCount innerMaxIdx < innerBaseCount
? buildHunkContextHashesForFile( ? buildHunkContextHashesForFile(
content?.originalFullContent, content?.originalFullContent,
content?.modifiedFullContent, content?.modifiedFullContent,
baseCount innerBaseCount
) )
: undefined; : undefined;
await api.review.applyDecisions({ await api.review.applyDecisions({

View file

@ -13,12 +13,19 @@ export class IpcError extends Error {
} }
} }
/** Error messages that represent expected transient states, not real failures. */
const EXPECTED_IPC_SIGNALS = ['TEAM_PROVISIONING'];
export async function unwrapIpc<T>(operation: string, fn: () => Promise<T>): Promise<T> { export async function unwrapIpc<T>(operation: string, fn: () => Promise<T>): Promise<T> {
try { try {
return await fn(); return await fn();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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); throw new IpcError(operation, message, error);
} }
} }