* Done. The ESLint rule is now in place, banning optional props in all ui-new components.
**Summary:**
- Added the rule to `frontend/.eslintrc.cjs` targeting `TSPropertySignature[optional=true]`
- The rule applies to all files under `src/components/ui-new/**/*.{ts,tsx}`
**Impact Assessment - 100 violations across:**
| Category | Files | Violations |
|----------|-------|------------|
| **primitives/** | 11 files | ~40 violations |
| **views/** | 5 files | ~15 violations |
| **containers/** | 5 files | ~10 violations |
| **dialogs/** | 7 files | ~15 violations |
| **Root-level** | 5 files | ~15 violations |
| **types/** | 1 file | 5 violations |
The most affected files are:
- `primitives/Dropdown.tsx` (14 violations)
- `primitives/RepoCard.tsx` (12 violations)
- `NewDisplayConversationEntry.tsx` (11 violations)
- `primitives/CommandBar.tsx` (5 violations)
Would you like me to start refactoring these components to comply with the new rule?
* Done. The rule now only applies to `containers/`.
**Summary:**
- Removed the optional props ban from the general `ui-new/**` override
- Added a new override specifically for `src/components/ui-new/containers/**/*.{ts,tsx}`
**Impact reduced from 100 to 48 violations**, all now in container components:
| File | Violations |
|------|------------|
| SessionChatBoxContainer.tsx | 11 |
| DiffViewCardWithComments.tsx | 9 |
| SearchableDropdownContainer.tsx | 6 |
| FileTreeContainer.tsx | 3 |
| PreviewControlsContainer.tsx | 3 |
| ChangesPanelContainer.tsx | 2 |
| LogsContentContainer.tsx | 2 |
| PreviewBrowserContainer.tsx | 2 |
| WorkspacesLayout.tsx | 2 |
| WorkspacesMainContainer.tsx | 2 |
| + 6 more files | 6 |
* All ESLint issues in `SessionChatBoxContainer.tsx` have been fixed. Here's a summary of the changes:
1. **Removed dead code**:
- Removed `taskId` prop (never passed by call site)
- Removed `workspaceId` aliasing and fallback (`propWorkspaceId ?? session?.workspace_id`)
2. **Updated interface** - Changed all optional props (`?`) to explicit types:
- Props that can be undefined use `Type | undefined` instead of `Type?`
- Props with defaults (`sessions`, `isNewSessionMode`) are now required in the interface
3. **Added explicit defaults** in destructuring for all props:
- `session = undefined`, `onSelectSession = undefined`, `projectId = undefined`, `onStartNewSession = undefined`, `workspaceId = undefined`
- `filesChanged = 0`, `linesAdded = 0`, `linesRemoved = 0`
- `sessions = []`, `isNewSessionMode = false`
* Done. I've refactored `SessionChatBoxContainer.tsx` to use a discriminated union for props. Here's a summary:
**Changes made:**
1. **Defined discriminated union type** with three modes:
- `existing-session`: Has a `session` (required), `onSelectSession`, `onStartNewSession`
- `new-session`: Has a `workspaceId` (required), `onSelectSession`
- `placeholder`: Has `workspaceId` (optional), no callbacks
2. **Updated component** to extract mode-specific values from props based on the discriminant
3. **Updated call site** (`WorkspacesMain.tsx`) to pass the correct mode and props based on state
**Benefits:**
- No more redundant `= undefined` defaults for props that are required in their respective modes
- Type safety ensures each mode has exactly the props it needs
- Self-documenting code with explicit mode discriminant
- ESLint and TypeScript checks pass
* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6
* Both lint and type check pass. I've removed `workspaceId` from `PlaceholderProps` since it's not needed when the editor is disabled in placeholder mode.
* All tasks are complete. Here's a summary of the changes made:
## Summary
I've refactored `DiffViewCardWithComments.tsx` and its related files to fix 11 ESLint errors (9 in DiffViewCardWithComments, 1 in CommentWidgetLine, 1 in ReviewCommentRenderer).
### Changes Made:
1. **`DiffViewCardWithComments.tsx`** - Converted to use discriminated unions:
- `DiffInput` type: Changed optional props (`oldPath?`, `changeKind?`, `hasLineNumbers?`) to explicit union types (`oldPath: string | undefined`)
- `DiffViewCardWithCommentsProps`: Split into two modes using a discriminated union:
- `CollapsibleProps` (mode: 'collapsible') - requires `expanded` and `onToggle`
- `StaticProps` (mode: 'static') - always expanded, no toggle
- Base props (`status`, `className`, `projectId`, `attemptId`) now use `T | undefined` instead of `T?`
2. **`CommentWidgetLine.tsx`** - Changed `projectId?: string` to `projectId: string | undefined`
3. **`ReviewCommentRenderer.tsx`** - Changed `projectId?: string` to `projectId: string | undefined`
4. **`ChangesPanel.tsx`** (callsite) - Updated to pass the new required props:
- Added `mode="collapsible"`
- Added `status={undefined}`
- Added `className=""`
### Verification:
- ✅ ESLint passes for all modified files
- ✅ TypeScript type check passes
* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6
* Done. Here's a summary of all changes:
## Summary
Refactored `DiffViewCardWithComments.tsx` and its callsites to fix ESLint issues:
### Changes Made:
1. **`DiffViewCardWithComments.tsx`**:
- Removed unused `status` prop and `ToolStatusDot` rendering
- Made `projectId` and `attemptId` required strings (no longer `undefined`)
- Removed conditional check for `attemptId` on the Open in IDE button (always shown now)
2. **`ChangesPanel.tsx`**:
- Made `projectId` and `attemptId` required strings
- Removed `status={undefined}` from the component call
3. **`CommentWidgetLine.tsx`**:
- Changed `projectId: string | undefined` to `projectId: string`
4. **`ReviewCommentRenderer.tsx`**:
- Changed `projectId: string | undefined` to `projectId: string`
5. **`ChangesPanelContainer.tsx`**:
- Made `className` and `attemptId` required props
- Added guard: if `projectId` is unavailable, renders empty panel
6. **`WorkspacesLayout.tsx`**:
- Added guard: only renders `ChangesPanelContainer` when `selectedWorkspace?.id` exists
- Passes required `className=""` prop
### Verification:
- ✅ TypeScript type check passes
- ✅ ESLint passes for all modified files
* Done. Changed `changeKind` from `DiffChangeKind | undefined` to `DiffChangeKind` in the `DiffInput` type. Both TypeScript and ESLint pass.
* Done. I've successfully refactored `SearchableDropdownContainer.tsx` to fix the 6 ESLint errors:
**Changes made:**
1. **`SearchableDropdownContainer.tsx`** (interface):
- `selectedValue: string | null` (removed `?`)
- `filterItem: ((item: T, query: string) => boolean) | null` (removed `?`, added `| null`)
- `contentClassName: string` (removed `?`)
- `placeholder: string` (removed `?`)
- `emptyMessage: string` (removed `?`)
- `getItemBadge: ((item: T) => string | undefined) | null` (removed `?`, added `| null`)
2. **`SearchableDropdownContainer.tsx`** (implementation):
- Removed default values from destructuring (`placeholder = 'Search'`, `emptyMessage = 'No items found'`)
- Changed `if (filterItem)` to `if (filterItem !== null)`
- Changed `getItemBadge={getItemBadge}` to `getItemBadge={getItemBadge ?? undefined}`
3. **`RepoCardSimple.tsx`** (callsite):
- Added `filterItem={null}`
- Changed `selectedValue={selectedBranch}` to `selectedValue={selectedBranch ?? null}` to handle the case when `selectedBranch` is `undefined`
All ESLint errors for `SearchableDropdownContainer.tsx` are now resolved, and TypeScript compiles without errors.
* Refactor FileTreeContainer to fix ESLint errors for optional props
- Make all props required in FileTreeContainerProps interface
- Add guard in RightSidebar to only render Changes section when
selectedWorkspace exists, ensuring workspaceId is always defined
- Remove redundant null check for onSelectFile callback
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Refactor NewDisplayConversationEntry to fix ESLint errors for optional props
- Remove dead `task` prop from NewDisplayConversationEntry and ConversationListContainer
(was only passed to legacy DisplayConversationEntry for entry types that don't use it)
- Make `executionProcessId` and `taskAttempt` required props
- Convert internal helper component optional props from `prop?: T` to `prop: T | undefined`
to satisfy ESLint while preserving the same runtime behavior
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Refactor VirtualizedProcessLogs to fix ESLint errors for optional props
Made search-related props required instead of optional:
- searchQuery: string (was optional)
- matchIndices: number[] (was optional)
- currentMatchIndex: number (was optional)
Updated callsites to provide explicit "no search" values where search
functionality is not used.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6
* Refactor container components to fix ESLint errors for optional props
Made the following props required (parents always provide them):
- BrowseRepoButtonContainer: disabled
- CopyButton: disabled
- WorkspacesMainContainer: isNewSessionMode, onStartNewSession
- LogsContentContainer: className
- PreviewBrowserContainer: attemptId, className
- PreviewControlsContainer: attemptId, className
Changed to union types (legitimately optional values):
- LogsContentContainer: command: string | undefined
- ProjectSelectorContainer: selectedProjectName: string | undefined
Added guards in parent components to only render children when
required data is available (RightSidebar, WorkspacesLayout).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6
* Refactor WorkspacesLayout to fix ESLint errors for optional props
Extract create mode sections into dedicated container components:
- CreateModeProjectSectionContainer: project selector with useCreateMode
- CreateModeReposSectionContainer: repos list with branch auto-selection
- CreateModeAddReposSectionContainer: add repos section
This removes the ModeProviderProps interface with optional props that
violated the ESLint rule. Now CreateModeProvider is only rendered when
in create mode, and each container manages its own context access.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
716 lines
21 KiB
TypeScript
716 lines
21 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
type Session,
|
|
type ToolStatus,
|
|
type BaseCodingAgent,
|
|
} from 'shared/types';
|
|
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
|
|
import { useExecutionProcesses } from '@/hooks/useExecutionProcesses';
|
|
import { useUserSystem } from '@/components/ConfigProvider';
|
|
import { useApprovalFeedbackOptional } from '@/contexts/ApprovalFeedbackContext';
|
|
import { useMessageEditContext } from '@/contexts/MessageEditContext';
|
|
import { useEntries, useTokenUsage } from '@/contexts/EntriesContext';
|
|
import { useReviewOptional } from '@/contexts/ReviewProvider';
|
|
import { useActions } from '@/contexts/ActionsContext';
|
|
import { useTodos } from '@/hooks/useTodos';
|
|
import { getLatestProfileFromProcesses } from '@/utils/executor';
|
|
import { useExecutorSelection } from '@/hooks/useExecutorSelection';
|
|
import { useSessionMessageEditor } from '@/hooks/useSessionMessageEditor';
|
|
import { useSessionQueueInteraction } from '@/hooks/useSessionQueueInteraction';
|
|
import { useSessionSend } from '@/hooks/useSessionSend';
|
|
import { useSessionAttachments } from '@/hooks/useSessionAttachments';
|
|
import { useMessageEditRetry } from '@/hooks/useMessageEditRetry';
|
|
import { useBranchStatus } from '@/hooks/useBranchStatus';
|
|
import { useApprovalMutation } from '@/hooks/useApprovalMutation';
|
|
import { workspaceSummaryKeys } from '@/components/ui-new/hooks/useWorkspaces';
|
|
import {
|
|
SessionChatBox,
|
|
type ExecutionStatus,
|
|
} from '../primitives/SessionChatBox';
|
|
import {
|
|
useWorkspacePanelState,
|
|
RIGHT_MAIN_PANEL_MODES,
|
|
} from '@/stores/useUiPreferencesStore';
|
|
import { Actions, type ActionDefinition } from '../actions';
|
|
import {
|
|
isActionVisible,
|
|
useActionVisibilityContext,
|
|
} from '../actions/useActionVisibility';
|
|
|
|
/** Compute execution status from boolean flags */
|
|
function computeExecutionStatus(params: {
|
|
isInFeedbackMode: boolean;
|
|
isInEditMode: boolean;
|
|
isStopping: boolean;
|
|
isQueueLoading: boolean;
|
|
isSendingFollowUp: boolean;
|
|
isQueued: boolean;
|
|
isAttemptRunning: boolean;
|
|
}): ExecutionStatus {
|
|
if (params.isInFeedbackMode) return 'feedback';
|
|
if (params.isInEditMode) return 'edit';
|
|
if (params.isStopping) return 'stopping';
|
|
if (params.isQueueLoading) return 'queue-loading';
|
|
if (params.isSendingFollowUp) return 'sending';
|
|
if (params.isQueued) return 'queued';
|
|
if (params.isAttemptRunning) return 'running';
|
|
return 'idle';
|
|
}
|
|
|
|
/** Shared props across all modes */
|
|
interface SharedProps {
|
|
/** Available sessions for this workspace */
|
|
sessions: Session[];
|
|
/** Project ID for file search in typeahead */
|
|
projectId: string | undefined;
|
|
/** Number of files changed in current session */
|
|
filesChanged: number;
|
|
/** Number of lines added */
|
|
linesAdded: number;
|
|
/** Number of lines removed */
|
|
linesRemoved: number;
|
|
}
|
|
|
|
/** Props for existing session mode */
|
|
interface ExistingSessionProps extends SharedProps {
|
|
mode: 'existing-session';
|
|
/** The current session */
|
|
session: Session;
|
|
/** Called when a session is selected */
|
|
onSelectSession: (sessionId: string) => void;
|
|
/** Callback to start new session mode */
|
|
onStartNewSession: (() => void) | undefined;
|
|
}
|
|
|
|
/** Props for new session mode */
|
|
interface NewSessionProps extends SharedProps {
|
|
mode: 'new-session';
|
|
/** Workspace ID for creating new sessions */
|
|
workspaceId: string;
|
|
/** Called when a session is selected */
|
|
onSelectSession: (sessionId: string) => void;
|
|
}
|
|
|
|
/** Props for placeholder mode (no workspace selected) */
|
|
interface PlaceholderProps extends SharedProps {
|
|
mode: 'placeholder';
|
|
}
|
|
|
|
type SessionChatBoxContainerProps =
|
|
| ExistingSessionProps
|
|
| NewSessionProps
|
|
| PlaceholderProps;
|
|
|
|
export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) {
|
|
const { mode, sessions, projectId, filesChanged, linesAdded, linesRemoved } =
|
|
props;
|
|
|
|
// Extract mode-specific values
|
|
const session = mode === 'existing-session' ? props.session : undefined;
|
|
const workspaceId =
|
|
mode === 'existing-session'
|
|
? props.session.workspace_id
|
|
: mode === 'new-session'
|
|
? props.workspaceId
|
|
: undefined;
|
|
const isNewSessionMode = mode === 'new-session';
|
|
const onSelectSession =
|
|
mode === 'placeholder' ? undefined : props.onSelectSession;
|
|
const onStartNewSession =
|
|
mode === 'existing-session' ? props.onStartNewSession : undefined;
|
|
|
|
const sessionId = session?.id;
|
|
const queryClient = useQueryClient();
|
|
|
|
const { executeAction } = useActions();
|
|
const actionCtx = useActionVisibilityContext();
|
|
const { rightMainPanelMode, setRightMainPanelMode } =
|
|
useWorkspacePanelState(workspaceId);
|
|
|
|
const handleViewCode = useCallback(() => {
|
|
setRightMainPanelMode(
|
|
rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES
|
|
? null
|
|
: RIGHT_MAIN_PANEL_MODES.CHANGES
|
|
);
|
|
}, [rightMainPanelMode, setRightMainPanelMode]);
|
|
|
|
// Get entries early to extract pending approval for scratch key
|
|
const { entries } = useEntries();
|
|
const tokenUsageInfo = useTokenUsage();
|
|
|
|
// Extract pending approval metadata from entries (needed for scratchId)
|
|
const pendingApproval = useMemo(() => {
|
|
for (const entry of entries) {
|
|
if (entry.type !== 'NORMALIZED_ENTRY') continue;
|
|
const entryType = entry.content.entry_type;
|
|
if (
|
|
entryType.type === 'tool_use' &&
|
|
entryType.status.status === 'pending_approval'
|
|
) {
|
|
const status = entryType.status as Extract<
|
|
ToolStatus,
|
|
{ status: 'pending_approval' }
|
|
>;
|
|
return {
|
|
approvalId: status.approval_id,
|
|
timeoutAt: status.timeout_at,
|
|
executionProcessId: entry.executionProcessId,
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}, [entries]);
|
|
|
|
// Use approval_id as scratch key when pending approval exists to avoid
|
|
// prefilling approval response with queued follow-up message
|
|
const scratchId = useMemo(() => {
|
|
if (pendingApproval?.approvalId) {
|
|
return pendingApproval.approvalId;
|
|
}
|
|
return isNewSessionMode ? workspaceId : sessionId;
|
|
}, [pendingApproval?.approvalId, isNewSessionMode, workspaceId, sessionId]);
|
|
|
|
// Execution state
|
|
const { isAttemptRunning, stopExecution, isStopping, processes } =
|
|
useAttemptExecution(workspaceId);
|
|
|
|
// Approval feedback context
|
|
const feedbackContext = useApprovalFeedbackOptional();
|
|
const isInFeedbackMode = !!feedbackContext?.activeApproval;
|
|
|
|
// Message edit context
|
|
const editContext = useMessageEditContext();
|
|
const isInEditMode = editContext.isInEditMode;
|
|
|
|
// Get todos from entries
|
|
const { inProgressTodo } = useTodos(entries);
|
|
|
|
// Review comments context (optional - only available when ReviewProvider wraps this)
|
|
const reviewContext = useReviewOptional();
|
|
const reviewMarkdown = useMemo(
|
|
() => reviewContext?.generateReviewMarkdown() ?? '',
|
|
[reviewContext]
|
|
);
|
|
const hasReviewComments = (reviewContext?.comments.length ?? 0) > 0;
|
|
|
|
// Approval mutation for approve/deny actions
|
|
const { approveAsync, denyAsync, isApproving, isDenying, denyError } =
|
|
useApprovalMutation();
|
|
|
|
// Branch status for edit retry and conflict detection
|
|
const { data: branchStatus } = useBranchStatus(workspaceId);
|
|
|
|
// Derive conflict state from branch status
|
|
const hasConflicts = useMemo(() => {
|
|
return (
|
|
branchStatus?.some((r) => (r.conflicted_files?.length ?? 0) > 0) ?? false
|
|
);
|
|
}, [branchStatus]);
|
|
|
|
const conflictedFilesCount = useMemo(() => {
|
|
return (
|
|
branchStatus?.reduce(
|
|
(sum, r) => sum + (r.conflicted_files?.length ?? 0),
|
|
0
|
|
) ?? 0
|
|
);
|
|
}, [branchStatus]);
|
|
|
|
// User profiles, config preference, and latest executor from processes
|
|
const { profiles, config } = useUserSystem();
|
|
|
|
// Fetch processes from last session to get full profile (only in new session mode)
|
|
const lastSessionId = isNewSessionMode ? sessions?.[0]?.id : undefined;
|
|
const { executionProcesses: lastSessionProcesses } =
|
|
useExecutionProcesses(lastSessionId);
|
|
|
|
// Compute latestProfileId: current processes > last session processes > session metadata
|
|
const latestProfileId = useMemo(() => {
|
|
// Current session's processes take priority
|
|
const fromProcesses = getLatestProfileFromProcesses(processes);
|
|
if (fromProcesses) return fromProcesses;
|
|
|
|
// Try full profile from last session's processes (includes variant)
|
|
const fromLastSession = getLatestProfileFromProcesses(lastSessionProcesses);
|
|
if (fromLastSession) return fromLastSession;
|
|
|
|
// Fallback: just executor from session metadata, no variant
|
|
const lastSessionExecutor = sessions?.[0]?.executor;
|
|
if (lastSessionExecutor) {
|
|
return {
|
|
executor: lastSessionExecutor as BaseCodingAgent,
|
|
variant: null,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}, [processes, lastSessionProcesses, sessions]);
|
|
|
|
// Message editor state
|
|
const {
|
|
localMessage,
|
|
setLocalMessage,
|
|
scratchData,
|
|
isScratchLoading,
|
|
hasInitialValue,
|
|
saveToScratch,
|
|
clearDraft,
|
|
cancelDebouncedSave,
|
|
handleMessageChange,
|
|
} = useSessionMessageEditor({ scratchId });
|
|
|
|
// Ref to access current message value for attachment handler
|
|
const localMessageRef = useRef(localMessage);
|
|
useEffect(() => {
|
|
localMessageRef.current = localMessage;
|
|
}, [localMessage]);
|
|
|
|
// Attachment handling - insert markdown when images are uploaded
|
|
const handleInsertMarkdown = useCallback(
|
|
(markdown: string) => {
|
|
const currentMessage = localMessageRef.current;
|
|
const newMessage = currentMessage.trim()
|
|
? `${currentMessage}\n\n${markdown}`
|
|
: markdown;
|
|
setLocalMessage(newMessage);
|
|
},
|
|
[setLocalMessage]
|
|
);
|
|
|
|
const { uploadFiles, localImages, clearUploadedImages } =
|
|
useSessionAttachments(workspaceId, handleInsertMarkdown);
|
|
|
|
// Executor/variant selection
|
|
const {
|
|
effectiveExecutor,
|
|
executorOptions,
|
|
handleExecutorChange,
|
|
selectedVariant,
|
|
variantOptions,
|
|
setSelectedVariant: setVariantFromHook,
|
|
} = useExecutorSelection({
|
|
profiles,
|
|
latestProfileId,
|
|
isNewSessionMode,
|
|
scratchVariant: scratchData?.variant,
|
|
configExecutorProfile: config?.executor_profile,
|
|
});
|
|
|
|
// Wrap variant change to also save to scratch
|
|
const setSelectedVariant = useCallback(
|
|
(variant: string | null) => {
|
|
setVariantFromHook(variant);
|
|
saveToScratch(localMessage, variant);
|
|
},
|
|
[setVariantFromHook, saveToScratch, localMessage]
|
|
);
|
|
|
|
// Queue interaction
|
|
const {
|
|
isQueued,
|
|
queuedMessage,
|
|
isQueueLoading,
|
|
queueMessage,
|
|
cancelQueue,
|
|
refreshQueueStatus,
|
|
} = useSessionQueueInteraction({ sessionId });
|
|
|
|
// Send actions
|
|
const {
|
|
send,
|
|
isSending,
|
|
error: sendError,
|
|
clearError,
|
|
} = useSessionSend({
|
|
sessionId,
|
|
workspaceId,
|
|
isNewSessionMode,
|
|
effectiveExecutor,
|
|
onSelectSession,
|
|
});
|
|
|
|
const handleSend = useCallback(async () => {
|
|
const messageParts = [reviewMarkdown, localMessage].filter(Boolean);
|
|
const combinedMessage = messageParts.join('\n\n');
|
|
|
|
const success = await send(combinedMessage, selectedVariant);
|
|
if (success) {
|
|
cancelDebouncedSave();
|
|
setLocalMessage('');
|
|
clearUploadedImages();
|
|
if (isNewSessionMode) await clearDraft();
|
|
reviewContext?.clearComments();
|
|
}
|
|
}, [
|
|
send,
|
|
localMessage,
|
|
reviewMarkdown,
|
|
selectedVariant,
|
|
cancelDebouncedSave,
|
|
setLocalMessage,
|
|
clearUploadedImages,
|
|
isNewSessionMode,
|
|
clearDraft,
|
|
reviewContext,
|
|
]);
|
|
|
|
// Track previous process count for queue refresh
|
|
const prevProcessCountRef = useRef(processes.length);
|
|
|
|
// Refresh queue status when execution stops or new process starts
|
|
useEffect(() => {
|
|
const prevCount = prevProcessCountRef.current;
|
|
prevProcessCountRef.current = processes.length;
|
|
|
|
if (!workspaceId) return;
|
|
|
|
if (!isAttemptRunning) {
|
|
refreshQueueStatus();
|
|
return;
|
|
}
|
|
|
|
if (processes.length > prevCount) {
|
|
refreshQueueStatus();
|
|
}
|
|
}, [isAttemptRunning, workspaceId, processes.length, refreshQueueStatus]);
|
|
|
|
// Queue message handler
|
|
const handleQueueMessage = useCallback(async () => {
|
|
// Allow queueing if there's a message OR review comments
|
|
if (!localMessage.trim() && !reviewMarkdown) return;
|
|
|
|
// Combine review comments with user message
|
|
const messageParts = [reviewMarkdown, localMessage].filter(Boolean);
|
|
const combinedMessage = messageParts.join('\n\n');
|
|
|
|
cancelDebouncedSave();
|
|
await saveToScratch(localMessage, selectedVariant);
|
|
await queueMessage(combinedMessage, selectedVariant);
|
|
}, [
|
|
localMessage,
|
|
reviewMarkdown,
|
|
selectedVariant,
|
|
queueMessage,
|
|
cancelDebouncedSave,
|
|
saveToScratch,
|
|
]);
|
|
|
|
// Editor change handler
|
|
const handleEditorChange = useCallback(
|
|
(value: string) => {
|
|
if (isQueued) cancelQueue();
|
|
handleMessageChange(value, selectedVariant);
|
|
if (sendError) clearError();
|
|
},
|
|
[
|
|
isQueued,
|
|
cancelQueue,
|
|
handleMessageChange,
|
|
selectedVariant,
|
|
sendError,
|
|
clearError,
|
|
]
|
|
);
|
|
|
|
// Handle feedback submission
|
|
const handleSubmitFeedback = useCallback(async () => {
|
|
if (!feedbackContext || !localMessage.trim()) return;
|
|
try {
|
|
await feedbackContext.submitFeedback(localMessage);
|
|
cancelDebouncedSave();
|
|
setLocalMessage('');
|
|
await clearDraft();
|
|
} catch {
|
|
// Error is handled in context
|
|
}
|
|
}, [
|
|
feedbackContext,
|
|
localMessage,
|
|
cancelDebouncedSave,
|
|
setLocalMessage,
|
|
clearDraft,
|
|
]);
|
|
|
|
// Handle cancel feedback mode
|
|
const handleCancelFeedback = useCallback(() => {
|
|
feedbackContext?.exitFeedbackMode();
|
|
}, [feedbackContext]);
|
|
|
|
// Message edit retry mutation
|
|
const editRetryMutation = useMessageEditRetry(sessionId ?? '', () => {
|
|
// On success, clear edit mode and reset editor
|
|
editContext.cancelEdit();
|
|
cancelDebouncedSave();
|
|
setLocalMessage('');
|
|
});
|
|
|
|
// Handle edit submission
|
|
const handleSubmitEdit = useCallback(async () => {
|
|
if (!editContext.activeEdit || !localMessage.trim()) return;
|
|
editRetryMutation.mutate({
|
|
message: localMessage,
|
|
variant: selectedVariant,
|
|
executionProcessId: editContext.activeEdit.processId,
|
|
branchStatus,
|
|
processes,
|
|
});
|
|
}, [
|
|
editContext.activeEdit,
|
|
localMessage,
|
|
selectedVariant,
|
|
branchStatus,
|
|
processes,
|
|
editRetryMutation,
|
|
]);
|
|
|
|
// Handle cancel edit mode
|
|
const handleCancelEdit = useCallback(() => {
|
|
editContext.cancelEdit();
|
|
setLocalMessage('');
|
|
}, [editContext, setLocalMessage]);
|
|
|
|
// Populate editor with original message when entering edit mode
|
|
const prevEditRef = useRef(editContext.activeEdit);
|
|
useEffect(() => {
|
|
if (editContext.activeEdit && !prevEditRef.current) {
|
|
// Just entered edit mode - populate with original message
|
|
setLocalMessage(editContext.activeEdit.originalMessage);
|
|
}
|
|
prevEditRef.current = editContext.activeEdit;
|
|
}, [editContext.activeEdit, setLocalMessage]);
|
|
|
|
// Toolbar actions handler - intercepts action execution to provide extra context
|
|
const handleToolbarAction = useCallback(
|
|
(action: ActionDefinition) => {
|
|
if (action.requiresTarget && workspaceId) {
|
|
executeAction(action, workspaceId);
|
|
} else {
|
|
executeAction(action);
|
|
}
|
|
},
|
|
[executeAction, workspaceId]
|
|
);
|
|
|
|
// Define which actions appear in the toolbar
|
|
const toolbarActionsList = useMemo(
|
|
() =>
|
|
[Actions.StartReview].filter((action) =>
|
|
isActionVisible(action, actionCtx)
|
|
),
|
|
[actionCtx]
|
|
);
|
|
|
|
// Handle approve action
|
|
const handleApprove = useCallback(async () => {
|
|
if (!pendingApproval) return;
|
|
|
|
// Exit feedback mode if active
|
|
feedbackContext?.exitFeedbackMode();
|
|
|
|
try {
|
|
await approveAsync({
|
|
approvalId: pendingApproval.approvalId,
|
|
executionProcessId: pendingApproval.executionProcessId,
|
|
});
|
|
|
|
// Invalidate workspace summary cache to update sidebar
|
|
queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });
|
|
} catch {
|
|
// Error is handled by mutation
|
|
}
|
|
}, [pendingApproval, feedbackContext, approveAsync, queryClient]);
|
|
|
|
// Handle request changes (deny with feedback)
|
|
const handleRequestChanges = useCallback(async () => {
|
|
if (!pendingApproval || !localMessage.trim()) return;
|
|
|
|
try {
|
|
await denyAsync({
|
|
approvalId: pendingApproval.approvalId,
|
|
executionProcessId: pendingApproval.executionProcessId,
|
|
reason: localMessage.trim(),
|
|
});
|
|
cancelDebouncedSave();
|
|
setLocalMessage('');
|
|
await clearDraft();
|
|
|
|
// Invalidate workspace summary cache to update sidebar
|
|
queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });
|
|
} catch {
|
|
// Error is handled by mutation
|
|
}
|
|
}, [
|
|
pendingApproval,
|
|
localMessage,
|
|
denyAsync,
|
|
cancelDebouncedSave,
|
|
setLocalMessage,
|
|
clearDraft,
|
|
queryClient,
|
|
]);
|
|
|
|
// Check if approval is timed out
|
|
const isApprovalTimedOut = pendingApproval
|
|
? new Date() > new Date(pendingApproval.timeoutAt)
|
|
: false;
|
|
|
|
const status = computeExecutionStatus({
|
|
isInFeedbackMode,
|
|
isInEditMode,
|
|
isStopping,
|
|
isQueueLoading,
|
|
isSendingFollowUp: isSending,
|
|
isQueued,
|
|
isAttemptRunning,
|
|
});
|
|
|
|
// During loading, render with empty editor to preserve container UI
|
|
// In approval mode, don't show queued message - it's for follow-up, not approval response
|
|
const editorValue = useMemo(() => {
|
|
if (isScratchLoading || !hasInitialValue) return '';
|
|
if (pendingApproval) return localMessage;
|
|
return queuedMessage ?? localMessage;
|
|
}, [
|
|
isScratchLoading,
|
|
hasInitialValue,
|
|
pendingApproval,
|
|
queuedMessage,
|
|
localMessage,
|
|
]);
|
|
|
|
// In placeholder mode, render a disabled version to maintain visual structure
|
|
if (mode === 'placeholder') {
|
|
return (
|
|
<SessionChatBox
|
|
status="idle"
|
|
workspaceId={workspaceId}
|
|
projectId={projectId}
|
|
tokenUsageInfo={tokenUsageInfo}
|
|
editor={{
|
|
value: '',
|
|
onChange: () => {},
|
|
}}
|
|
actions={{
|
|
onSend: () => {},
|
|
onQueue: () => {},
|
|
onCancelQueue: () => {},
|
|
onStop: () => {},
|
|
onPasteFiles: () => {},
|
|
}}
|
|
session={{
|
|
sessions: [],
|
|
selectedSessionId: undefined,
|
|
onSelectSession: () => {},
|
|
isNewSessionMode: false,
|
|
onNewSession: undefined,
|
|
}}
|
|
stats={{
|
|
filesChanged: 0,
|
|
linesAdded: 0,
|
|
linesRemoved: 0,
|
|
}}
|
|
onViewCode={handleViewCode}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SessionChatBox
|
|
status={status}
|
|
onViewCode={handleViewCode}
|
|
workspaceId={workspaceId}
|
|
projectId={projectId}
|
|
tokenUsageInfo={tokenUsageInfo}
|
|
editor={{
|
|
value: editorValue,
|
|
onChange: handleEditorChange,
|
|
}}
|
|
actions={{
|
|
onSend: handleSend,
|
|
onQueue: handleQueueMessage,
|
|
onCancelQueue: cancelQueue,
|
|
onStop: stopExecution,
|
|
onPasteFiles: uploadFiles,
|
|
}}
|
|
variant={{
|
|
selected: selectedVariant,
|
|
options: variantOptions,
|
|
onChange: setSelectedVariant,
|
|
}}
|
|
session={{
|
|
sessions,
|
|
selectedSessionId: sessionId,
|
|
onSelectSession: onSelectSession ?? (() => {}),
|
|
isNewSessionMode,
|
|
onNewSession: onStartNewSession,
|
|
}}
|
|
toolbarActions={{
|
|
actions: toolbarActionsList,
|
|
context: actionCtx,
|
|
onExecuteAction: handleToolbarAction,
|
|
}}
|
|
stats={{
|
|
filesChanged,
|
|
linesAdded,
|
|
linesRemoved,
|
|
hasConflicts,
|
|
conflictedFilesCount,
|
|
}}
|
|
error={sendError}
|
|
agent={latestProfileId?.executor}
|
|
inProgressTodo={inProgressTodo}
|
|
executor={
|
|
isNewSessionMode
|
|
? {
|
|
selected: effectiveExecutor,
|
|
options: executorOptions,
|
|
onChange: handleExecutorChange,
|
|
}
|
|
: undefined
|
|
}
|
|
feedbackMode={
|
|
feedbackContext
|
|
? {
|
|
isActive: isInFeedbackMode,
|
|
onSubmitFeedback: handleSubmitFeedback,
|
|
onCancel: handleCancelFeedback,
|
|
isSubmitting: feedbackContext.isSubmitting,
|
|
error: feedbackContext.error,
|
|
isTimedOut: feedbackContext.isTimedOut,
|
|
}
|
|
: undefined
|
|
}
|
|
approvalMode={
|
|
pendingApproval
|
|
? {
|
|
isActive: true,
|
|
onApprove: handleApprove,
|
|
onRequestChanges: handleRequestChanges,
|
|
isSubmitting: isApproving || isDenying,
|
|
isTimedOut: isApprovalTimedOut,
|
|
error: denyError?.message ?? null,
|
|
}
|
|
: undefined
|
|
}
|
|
editMode={{
|
|
isActive: isInEditMode,
|
|
onSubmitEdit: handleSubmitEdit,
|
|
onCancel: handleCancelEdit,
|
|
isSubmitting: editRetryMutation.isPending,
|
|
}}
|
|
reviewComments={
|
|
hasReviewComments && reviewContext
|
|
? {
|
|
count: reviewContext.comments.length,
|
|
previewMarkdown: reviewMarkdown,
|
|
onClear: reviewContext.clearComments,
|
|
}
|
|
: undefined
|
|
}
|
|
localImages={localImages}
|
|
/>
|
|
);
|
|
}
|