Add session-scoped routes for follow-up and queue operations (Vibe Kanban) (#1606)
* Yup, I'll hold off and do nothing for now. Let me know when you're ready to proceed with the session routes task, and I'll start exploring the codebase and planning the implementation. All cherry-pick conflicts resolved: 1. **`sessions/queue.rs`** - Fixed analytics to use `session.id` and `session.workspace_id` (2 locations) 2. **`task_attempts.rs`** - Removed the old `follow_up()` function that was already moved to `sessions/mod.rs` No remaining conflict markers in the routes directory. All fixes complete: 1. **`sessions/queue.rs`** ✓ - Fixed analytics to use `session.id` and `session.workspace_id` 2. **`task_attempts.rs`** ✓ - Removed old `follow_up` function (moved to sessions) 3. **`sessions/mod.rs`** ✓ - Fixed to use `find_latest_coding_agent_turn_session_id(pool, session.id)` instead of non-existent method The session-based `follow_up` now correctly scopes the agent_session_id lookup to the current session rather than across all workspace sessions. * All done: 1. ✓ `container.rs` - Changed to `find_latest_coding_agent_turn_session_id(pool, ctx.session.id)` 2. ✓ `pr.rs` - Changed to `find_latest_coding_agent_turn_session_id(pool, session.id)` 3. ✓ Deleted `find_latest_agent_session_id_by_workspace` from `execution_process.rs` All workspace-scoped `agent_session_id` lookups have been converted to session-scoped lookups and the unused method has been removed. Cleanup script changes for task attempt afc116fb-e639-4150-a299-b05c1f14f61e Done. Removed the unnecessary `Session::create` call and now using `&ctx.session` directly. Lint, db * Done. Simplified the validation: - Removed the extra `Session::find_by_id` DB call - Now directly checks `process.session_id != session.id` - Updated error message to "Process does not belong to this session"
This commit is contained in:
@@ -126,11 +126,11 @@ export function NextActionCard({
|
||||
}, [attemptId, task]);
|
||||
|
||||
const handleRunSetup = useCallback(async () => {
|
||||
if (!attemptId || !attempt) return;
|
||||
if (!attemptId || !attempt?.session?.executor) return;
|
||||
try {
|
||||
await attemptsApi.runAgentSetup(attemptId, {
|
||||
executor_profile_id: {
|
||||
executor: attempt.executor as BaseCodingAgent,
|
||||
executor: attempt.session.executor as BaseCodingAgent,
|
||||
variant: null,
|
||||
},
|
||||
});
|
||||
@@ -140,12 +140,14 @@ export function NextActionCard({
|
||||
}, [attemptId, attempt]);
|
||||
|
||||
const canAutoSetup = !!(
|
||||
attempt?.executor &&
|
||||
capabilities?.[attempt.executor]?.includes(BaseAgentCapability.SETUP_HELPER)
|
||||
attempt?.session?.executor &&
|
||||
capabilities?.[attempt.session.executor]?.includes(
|
||||
BaseAgentCapability.SETUP_HELPER
|
||||
)
|
||||
);
|
||||
|
||||
const setupHelpText = canAutoSetup
|
||||
? t('attempt.setupHelpText', { agent: attempt?.executor })
|
||||
? t('attempt.setupHelpText', { agent: attempt?.session?.executor })
|
||||
: null;
|
||||
|
||||
const editorName = getIdeName(config?.editor?.editor_type);
|
||||
|
||||
@@ -37,6 +37,9 @@ export function RetryEditorInline({
|
||||
const [message, setMessage] = useState(initialContent);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
|
||||
// Get sessionId from attempt's session
|
||||
const sessionId = attempt.session?.id;
|
||||
|
||||
// Extract variant from the process being retried
|
||||
const processVariant = useMemo<string | null>(() => {
|
||||
const process = attemptData.processes?.find(
|
||||
@@ -71,13 +74,13 @@ export function RetryEditorInline({
|
||||
});
|
||||
|
||||
const retryMutation = useRetryProcess(
|
||||
attemptId,
|
||||
sessionId ?? '',
|
||||
() => onCancelled?.(),
|
||||
(err) => setSendError((err as Error)?.message || 'Failed to send retry')
|
||||
);
|
||||
|
||||
const isSending = retryMutation.isPending;
|
||||
const canSend = !isAttemptRunning && !!message.trim();
|
||||
const canSend = !isAttemptRunning && !!message.trim() && !!sessionId;
|
||||
|
||||
const onCancel = () => {
|
||||
onCancelled?.();
|
||||
@@ -170,7 +173,7 @@ export function RetryEditorInline({
|
||||
<VariantSelector
|
||||
selectedVariant={selectedVariant}
|
||||
onChange={setSelectedVariant}
|
||||
currentProfile={profiles?.[attempt.executor] ?? null}
|
||||
currentProfile={profiles?.[attempt.session?.executor ?? ''] ?? null}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -23,8 +23,8 @@ const UserMessage = ({
|
||||
const { isAttemptRunning } = useAttemptExecution(taskAttempt?.id);
|
||||
|
||||
const canFork = !!(
|
||||
taskAttempt?.executor &&
|
||||
capabilities?.[taskAttempt.executor]?.includes(
|
||||
taskAttempt?.session?.executor &&
|
||||
capabilities?.[taskAttempt.session.executor]?.includes(
|
||||
BaseAgentCapability.SESSION_FORK
|
||||
)
|
||||
);
|
||||
|
||||
@@ -99,8 +99,8 @@ const CreateAttemptDialogImpl = NiceModal.create<CreateAttemptDialogProps>(
|
||||
}, [modal.visible, resetBranchSelection]);
|
||||
|
||||
const defaultProfile: ExecutorProfileId | null = useMemo(() => {
|
||||
if (latestAttempt?.executor) {
|
||||
const lastExec = latestAttempt.executor as BaseCodingAgent;
|
||||
if (latestAttempt?.session?.executor) {
|
||||
const lastExec = latestAttempt.session.executor as BaseCodingAgent;
|
||||
// If the last attempt used the same executor as the user's current preference,
|
||||
// we assume they want to use their preferred variant as well.
|
||||
// Otherwise, we default to the "default" variant (null) since we don't know
|
||||
@@ -116,7 +116,7 @@ const CreateAttemptDialogImpl = NiceModal.create<CreateAttemptDialogProps>(
|
||||
};
|
||||
}
|
||||
return config?.executor_profile ?? null;
|
||||
}, [latestAttempt?.executor, config?.executor_profile]);
|
||||
}, [latestAttempt?.session?.executor, config?.executor_profile]);
|
||||
|
||||
const effectiveProfile = userSelectedProfile ?? defaultProfile;
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const TaskAttemptPanel = ({
|
||||
<VirtualizedList key={attempt.id} attempt={attempt} task={task} />
|
||||
),
|
||||
followUp: (
|
||||
<TaskFollowUpSection task={task} selectedAttemptId={attempt.id} />
|
||||
<TaskFollowUpSection task={task} session={attempt.session} />
|
||||
),
|
||||
})}
|
||||
</RetryUiProvider>
|
||||
|
||||
@@ -80,7 +80,7 @@ const TaskPanel = ({ task }: TaskPanelProps) => {
|
||||
{
|
||||
id: 'executor',
|
||||
header: '',
|
||||
accessor: (attempt) => attempt.executor || 'Base Agent',
|
||||
accessor: (attempt) => attempt.session?.executor || 'Base Agent',
|
||||
className: 'pr-4',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -60,24 +60,30 @@ import { useQueueStatus } from '@/hooks/useQueueStatus';
|
||||
import { imagesApi, attemptsApi } from '@/lib/api';
|
||||
import { GitHubCommentsDialog } from '@/components/dialogs/tasks/GitHubCommentsDialog';
|
||||
import type { NormalizedComment } from '@/components/ui/wysiwyg/nodes/github-comment-node';
|
||||
import type { Session } from 'shared/types';
|
||||
|
||||
interface TaskFollowUpSectionProps {
|
||||
task: TaskWithAttemptStatus;
|
||||
selectedAttemptId?: string;
|
||||
session?: Session;
|
||||
}
|
||||
|
||||
export function TaskFollowUpSection({
|
||||
task,
|
||||
selectedAttemptId,
|
||||
session,
|
||||
}: TaskFollowUpSectionProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
const { projectId } = useProject();
|
||||
|
||||
// Derive IDs from session
|
||||
const workspaceId = session?.workspace_id;
|
||||
const sessionId = session?.id;
|
||||
|
||||
const { isAttemptRunning, stopExecution, isStopping, processes } =
|
||||
useAttemptExecution(selectedAttemptId, task.id);
|
||||
useAttemptExecution(workspaceId, task.id);
|
||||
|
||||
const { data: branchStatus, refetch: refetchBranchStatus } =
|
||||
useBranchStatus(selectedAttemptId);
|
||||
const { repos, selectedRepoId } = useAttemptRepo(selectedAttemptId);
|
||||
useBranchStatus(workspaceId);
|
||||
const { repos, selectedRepoId } = useAttemptRepo(workspaceId);
|
||||
|
||||
const getSelectedRepoId = useCallback(() => {
|
||||
return selectedRepoId ?? repos[0]?.id;
|
||||
@@ -91,7 +97,7 @@ export function TaskFollowUpSection({
|
||||
[branchStatus]
|
||||
);
|
||||
const { branch: attemptBranch, refetch: refetchAttemptBranch } =
|
||||
useAttemptBranch(selectedAttemptId);
|
||||
useAttemptBranch(workspaceId);
|
||||
const { profiles } = useUserSystem();
|
||||
const { comments, generateReviewMarkdown, clearComments } = useReview();
|
||||
const {
|
||||
@@ -127,7 +133,7 @@ export function TaskFollowUpSection({
|
||||
scratch,
|
||||
updateScratch,
|
||||
isLoading: isScratchLoading,
|
||||
} = useScratch(ScratchType.DRAFT_FOLLOW_UP, selectedAttemptId ?? '');
|
||||
} = useScratch(ScratchType.DRAFT_FOLLOW_UP, workspaceId ?? '');
|
||||
|
||||
// Derive the message and variant from scratch
|
||||
const scratchData: DraftFollowUpData | undefined =
|
||||
@@ -201,7 +207,7 @@ export function TaskFollowUpSection({
|
||||
// Uses scratchRef to avoid callback invalidation when scratch updates
|
||||
const saveToScratch = useCallback(
|
||||
async (message: string, variant: string | null) => {
|
||||
if (!selectedAttemptId) return;
|
||||
if (!workspaceId) return;
|
||||
// Don't create empty scratch entries - only save if there's actual content,
|
||||
// a variant is selected, or scratch already exists (to allow clearing a draft)
|
||||
if (!message.trim() && !variant && !scratchRef.current) return;
|
||||
@@ -216,7 +222,7 @@ export function TaskFollowUpSection({
|
||||
console.error('Failed to save follow-up draft', e);
|
||||
}
|
||||
},
|
||||
[selectedAttemptId, updateScratch]
|
||||
[workspaceId, updateScratch]
|
||||
);
|
||||
|
||||
// Wrapper to update variant and save to scratch immediately
|
||||
@@ -259,7 +265,7 @@ export function TaskFollowUpSection({
|
||||
queueMessage,
|
||||
cancelQueue,
|
||||
refresh: refreshQueueStatus,
|
||||
} = useQueueStatus(selectedAttemptId);
|
||||
} = useQueueStatus(sessionId);
|
||||
|
||||
// Track previous process count to detect new processes
|
||||
const prevProcessCountRef = useRef(processes.length);
|
||||
@@ -269,7 +275,7 @@ export function TaskFollowUpSection({
|
||||
const prevCount = prevProcessCountRef.current;
|
||||
prevProcessCountRef.current = processes.length;
|
||||
|
||||
if (!selectedAttemptId) return;
|
||||
if (!workspaceId) return;
|
||||
|
||||
// Refresh when execution stops
|
||||
if (!isAttemptRunning) {
|
||||
@@ -286,7 +292,7 @@ export function TaskFollowUpSection({
|
||||
}
|
||||
}, [
|
||||
isAttemptRunning,
|
||||
selectedAttemptId,
|
||||
workspaceId,
|
||||
processes.length,
|
||||
refreshQueueStatus,
|
||||
scratchData?.message,
|
||||
@@ -312,7 +318,7 @@ export function TaskFollowUpSection({
|
||||
// Send follow-up action
|
||||
const { isSendingFollowUp, followUpError, setFollowUpError, onSendFollowUp } =
|
||||
useFollowUpSend({
|
||||
attemptId: selectedAttemptId,
|
||||
sessionId,
|
||||
message: localMessage,
|
||||
conflictMarkdown: conflictResolutionInstructions,
|
||||
reviewMarkdown,
|
||||
@@ -329,7 +335,7 @@ export function TaskFollowUpSection({
|
||||
|
||||
// Separate logic for when textarea should be disabled vs when send button should be disabled
|
||||
const canTypeFollowUp = useMemo(() => {
|
||||
if (!selectedAttemptId || processes.length === 0 || isSendingFollowUp) {
|
||||
if (!workspaceId || processes.length === 0 || isSendingFollowUp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -338,7 +344,7 @@ export function TaskFollowUpSection({
|
||||
// Note: isQueued no longer blocks typing - editing auto-cancels the queue
|
||||
return true;
|
||||
}, [
|
||||
selectedAttemptId,
|
||||
workspaceId,
|
||||
processes.length,
|
||||
isSendingFollowUp,
|
||||
isRetryActive,
|
||||
@@ -369,22 +375,22 @@ export function TaskFollowUpSection({
|
||||
const hasAnyScript = true;
|
||||
|
||||
const handleRunSetupScript = useCallback(async () => {
|
||||
if (!selectedAttemptId || isAttemptRunning) return;
|
||||
if (!workspaceId || isAttemptRunning) return;
|
||||
try {
|
||||
await attemptsApi.runSetupScript(selectedAttemptId);
|
||||
await attemptsApi.runSetupScript(workspaceId);
|
||||
} catch (error) {
|
||||
console.error('Failed to run setup script:', error);
|
||||
}
|
||||
}, [selectedAttemptId, isAttemptRunning]);
|
||||
}, [workspaceId, isAttemptRunning]);
|
||||
|
||||
const handleRunCleanupScript = useCallback(async () => {
|
||||
if (!selectedAttemptId || isAttemptRunning) return;
|
||||
if (!workspaceId || isAttemptRunning) return;
|
||||
try {
|
||||
await attemptsApi.runCleanupScript(selectedAttemptId);
|
||||
await attemptsApi.runCleanupScript(workspaceId);
|
||||
} catch (error) {
|
||||
console.error('Failed to run cleanup script:', error);
|
||||
}
|
||||
}, [selectedAttemptId, isAttemptRunning]);
|
||||
}, [workspaceId, isAttemptRunning]);
|
||||
|
||||
// Handler to queue the current message for execution after agent finishes
|
||||
const handleQueueMessage = useCallback(async () => {
|
||||
@@ -469,14 +475,11 @@ export function TaskFollowUpSection({
|
||||
// Handle image paste - upload to container and insert markdown
|
||||
const handlePasteFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (!selectedAttemptId) return;
|
||||
if (!workspaceId) return;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const response = await imagesApi.uploadForAttempt(
|
||||
selectedAttemptId,
|
||||
file
|
||||
);
|
||||
const response = await imagesApi.uploadForAttempt(workspaceId, file);
|
||||
// Append markdown image to current message
|
||||
const imageMarkdown = ``;
|
||||
|
||||
@@ -503,7 +506,7 @@ export function TaskFollowUpSection({
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedAttemptId]
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
// Attachment button - file input ref and handlers
|
||||
@@ -527,12 +530,12 @@ export function TaskFollowUpSection({
|
||||
|
||||
// Handler for GitHub comments insertion
|
||||
const handleGitHubCommentClick = useCallback(async () => {
|
||||
if (!selectedAttemptId) return;
|
||||
if (!workspaceId) return;
|
||||
const repoId = getSelectedRepoId();
|
||||
if (!repoId) return;
|
||||
|
||||
const result = await GitHubCommentsDialog.show({
|
||||
attemptId: selectedAttemptId,
|
||||
attemptId: workspaceId,
|
||||
repoId,
|
||||
});
|
||||
if (result.comments.length > 0) {
|
||||
@@ -575,7 +578,7 @@ export function TaskFollowUpSection({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedAttemptId, getSelectedRepoId]);
|
||||
}, [workspaceId, getSelectedRepoId]);
|
||||
|
||||
// Stable onChange handler for WYSIWYGEditor
|
||||
const handleEditorChange = useCallback(
|
||||
@@ -637,19 +640,19 @@ export function TaskFollowUpSection({
|
||||
// When a process completes (e.g., agent resolved conflicts), refresh branch status promptly
|
||||
const prevRunningRef = useRef<boolean>(isAttemptRunning);
|
||||
useEffect(() => {
|
||||
if (prevRunningRef.current && !isAttemptRunning && selectedAttemptId) {
|
||||
if (prevRunningRef.current && !isAttemptRunning && workspaceId) {
|
||||
refetchBranchStatus();
|
||||
refetchAttemptBranch();
|
||||
}
|
||||
prevRunningRef.current = isAttemptRunning;
|
||||
}, [
|
||||
isAttemptRunning,
|
||||
selectedAttemptId,
|
||||
workspaceId,
|
||||
refetchBranchStatus,
|
||||
refetchAttemptBranch,
|
||||
]);
|
||||
|
||||
if (!selectedAttemptId) return null;
|
||||
if (!workspaceId) return null;
|
||||
|
||||
if (isScratchLoading) {
|
||||
return (
|
||||
@@ -688,7 +691,7 @@ export function TaskFollowUpSection({
|
||||
{/* Conflict notice and actions (optional UI) */}
|
||||
{branchStatus && (
|
||||
<FollowUpConflictSection
|
||||
selectedAttemptId={selectedAttemptId}
|
||||
workspaceId={workspaceId}
|
||||
attemptBranch={attemptBranch}
|
||||
branchStatus={branchStatus}
|
||||
isEditable={isEditable}
|
||||
@@ -734,7 +737,7 @@ export function TaskFollowUpSection({
|
||||
disabled={!isEditable}
|
||||
onPasteFiles={handlePasteFiles}
|
||||
projectId={projectId}
|
||||
taskAttemptId={selectedAttemptId}
|
||||
taskAttemptId={workspaceId}
|
||||
onCmdEnter={handleSubmitShortcut}
|
||||
className="min-h-[40px]"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAttemptConflicts } from '@/hooks/useAttemptConflicts';
|
||||
import type { RepoBranchStatus } from 'shared/types';
|
||||
|
||||
type Props = {
|
||||
selectedAttemptId?: string;
|
||||
workspaceId?: string;
|
||||
attemptBranch: string | null;
|
||||
branchStatus: RepoBranchStatus[] | undefined;
|
||||
isEditable: boolean;
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function FollowUpConflictSection({
|
||||
selectedAttemptId,
|
||||
workspaceId,
|
||||
attemptBranch,
|
||||
branchStatus,
|
||||
onResolve,
|
||||
@@ -28,9 +28,9 @@ export function FollowUpConflictSection({
|
||||
(r) => r.is_rebase_in_progress || (r.conflicted_files?.length ?? 0) > 0
|
||||
);
|
||||
const op = repoWithConflicts?.conflict_op ?? null;
|
||||
const openInEditor = useOpenInEditor(selectedAttemptId);
|
||||
const openInEditor = useOpenInEditor(workspaceId);
|
||||
const repoId = repoWithConflicts?.repo_id;
|
||||
const { abortConflicts } = useAttemptConflicts(selectedAttemptId, repoId);
|
||||
const { abortConflicts } = useAttemptConflicts(workspaceId, repoId);
|
||||
|
||||
// write using setAborting and read through abortingRef in async handlers
|
||||
const [aborting, setAborting] = useState(false);
|
||||
@@ -51,12 +51,12 @@ export function FollowUpConflictSection({
|
||||
onResolve={onResolve}
|
||||
enableResolve={enableResolve && !aborting}
|
||||
onOpenEditor={() => {
|
||||
if (!selectedAttemptId) return;
|
||||
if (!workspaceId) return;
|
||||
const first = repoWithConflicts.conflicted_files?.[0];
|
||||
openInEditor(first ? { filePath: first } : undefined);
|
||||
}}
|
||||
onAbort={async () => {
|
||||
if (!selectedAttemptId) return;
|
||||
if (!workspaceId) return;
|
||||
if (!enableAbort || abortingRef.current) return;
|
||||
try {
|
||||
setAborting(true);
|
||||
|
||||
Reference in New Issue
Block a user