Workspaces FE (#1733)
This commit is contained in:
committed by
GitHub
parent
fe2215ba85
commit
527febdc52
@@ -0,0 +1,612 @@
|
||||
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 { useUserSystem } from '@/components/ConfigProvider';
|
||||
import { useApprovalFeedbackOptional } from '@/contexts/ApprovalFeedbackContext';
|
||||
import { useMessageEditContext } from '@/contexts/MessageEditContext';
|
||||
import { useEntries } from '@/contexts/EntriesContext';
|
||||
import { useReviewOptional } from '@/contexts/ReviewProvider';
|
||||
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';
|
||||
|
||||
/** 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';
|
||||
}
|
||||
|
||||
interface SessionChatBoxContainerProps {
|
||||
/** The current session */
|
||||
session?: Session;
|
||||
/** Task ID for execution tracking */
|
||||
taskId?: string;
|
||||
/** Number of files changed in current session */
|
||||
filesChanged?: number;
|
||||
/** Number of lines added */
|
||||
linesAdded?: number;
|
||||
/** Number of lines removed */
|
||||
linesRemoved?: number;
|
||||
/** Callback to view code changes (toggle ChangesPanel) */
|
||||
onViewCode?: () => void;
|
||||
/** Available sessions for this workspace */
|
||||
sessions?: Session[];
|
||||
/** Called when a session is selected */
|
||||
onSelectSession?: (sessionId: string) => void;
|
||||
/** Project ID for file search in typeahead */
|
||||
projectId?: string;
|
||||
/** Whether user is creating a new session */
|
||||
isNewSessionMode?: boolean;
|
||||
/** Callback to start new session mode */
|
||||
onStartNewSession?: () => void;
|
||||
/** Workspace ID for creating new sessions */
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export function SessionChatBoxContainer({
|
||||
session,
|
||||
taskId,
|
||||
filesChanged,
|
||||
linesAdded,
|
||||
linesRemoved,
|
||||
onViewCode,
|
||||
sessions = [],
|
||||
onSelectSession,
|
||||
projectId,
|
||||
isNewSessionMode = false,
|
||||
onStartNewSession,
|
||||
workspaceId: propWorkspaceId,
|
||||
}: SessionChatBoxContainerProps) {
|
||||
const workspaceId = propWorkspaceId ?? session?.workspace_id;
|
||||
const sessionId = session?.id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get entries early to extract pending approval for scratch key
|
||||
const { entries } = useEntries();
|
||||
|
||||
// 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, taskId);
|
||||
|
||||
// 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();
|
||||
|
||||
// Get last used executor from the most recently used session in this workspace
|
||||
const lastSessionExecutor = useMemo(() => {
|
||||
if (!sessions?.length) return null;
|
||||
// Sessions are sorted by most recently used (first is most recent)
|
||||
const mostRecentSession = sessions[0];
|
||||
return mostRecentSession?.executor ?? null;
|
||||
}, [sessions]);
|
||||
|
||||
// Compute latestProfileId: from processes, or fall back to last session's executor
|
||||
const latestProfileId = useMemo(() => {
|
||||
// If we have processes (existing session), use them
|
||||
const fromProcesses = getLatestProfileFromProcesses(processes);
|
||||
if (fromProcesses) return fromProcesses;
|
||||
|
||||
// Fall back to last session's executor (useful for new session mode)
|
||||
if (lastSessionExecutor) {
|
||||
return {
|
||||
executor: lastSessionExecutor as BaseCodingAgent,
|
||||
variant: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [processes, lastSessionExecutor]);
|
||||
|
||||
// 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 () => {
|
||||
// Combine review comments with user message
|
||||
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();
|
||||
// Clear review comments after successful send
|
||||
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]);
|
||||
|
||||
// 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;
|
||||
|
||||
// Compute execution status
|
||||
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,
|
||||
]);
|
||||
|
||||
// Don't render if no session and not in new session mode
|
||||
if (!session && !isNewSessionMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionChatBox
|
||||
status={status}
|
||||
projectId={projectId}
|
||||
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,
|
||||
}}
|
||||
stats={{
|
||||
filesChanged,
|
||||
linesAdded,
|
||||
linesRemoved,
|
||||
onViewCode,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user