From a7bc2a228c4573936d5ec8da5e07c2e2a18ece40 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 18 Apr 2026 13:47:11 +0300 Subject: [PATCH] feat(todos): send todo to new session or worktree with model/agent/variant picker Add a dedicated TodoSendDialog that lets the user pick provider/model, reasoning variant and agent before opening a new session (or a fresh worktree branch) pre-seeded with the todo text and a hidden instructions prompt (plan.todo). --- .../session/ProjectNotesTodoPanel.tsx | 268 ++++++++++++++++-- .../src/components/session/TodoSendDialog.tsx | 229 +++++++++++++++ 2 files changed, 475 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/components/session/TodoSendDialog.tsx diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx index 9cae301a..8db127a0 100644 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -11,18 +11,26 @@ import { import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { - getProjectNotesAndTodos, + getProjectContextData, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, + readProjectPlanFile, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH, saveProjectNotesAndTodos, + type OpenChamberProjectPlanFileLink, type OpenChamberProjectTodoItem, type ProjectRef, } from '@/lib/openchamberConfig'; +import { generateBranchName } from '@/lib/git/branchNameGenerator'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useUIStore } from '@/stores/useUIStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { useInputStore } from '@/sync/input-store'; -import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; +import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; import { cn } from '@/lib/utils'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog'; interface ProjectNotesTodoPanelProps { projectRef: ProjectRef | null; @@ -32,6 +40,24 @@ interface ProjectNotesTodoPanelProps { className?: string; } +type PendingSendTarget = { + kind: 'session' | 'worktree'; + todoId: string; + todoText: string; +}; + +type ProjectPlanListItem = OpenChamberProjectPlanFileLink & { + title: string; +}; + +const toPlanListItem = async (plan: OpenChamberProjectPlanFileLink): Promise => { + const file = await readProjectPlanFile(plan.path); + return { + ...plan, + title: file?.title || plan.path.split('/').pop() || 'Plan', + }; +}; + const createTodoId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -52,10 +78,21 @@ export const ProjectNotesTodoPanel: React.FC = ({ const [newTodoText, setNewTodoText] = React.useState(''); const [sendingTodoId, setSendingTodoId] = React.useState(null); const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set()); + const [plans, setPlans] = React.useState([]); + const [pendingSendTarget, setPendingSendTarget] = React.useState(null); + const [isSendDialogSubmitting, setIsSendDialogSubmitting] = React.useState(false); + const [contextReloadTick, setContextReloadTick] = React.useState(0); + const notesHydratedRef = React.useRef(false); + const lastSavedNotesRef = React.useRef(''); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const createSession = useSessionUIStore((state) => state.createSession); + const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession); + const sendMessage = useSessionUIStore((state) => state.sendMessage); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setPendingInputText = useInputStore((state) => state.setPendingInputText); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); @@ -80,6 +117,7 @@ export const ProjectNotesTodoPanel: React.FC = ({ if (!projectRef) { setNotes(''); setTodos([]); + setPlans([]); setNewTodoText(''); setExpandedTodoIds(new Set()); return; @@ -90,12 +128,16 @@ export const ProjectNotesTodoPanel: React.FC = ({ (async () => { try { - const data = await getProjectNotesAndTodos(projectRef); + const data = await getProjectContextData(projectRef); + const nextPlans = await Promise.all(data.plans.map((plan) => toPlanListItem(plan))); if (cancelled) { return; } setNotes(data.notes); setTodos(data.todos); + setPlans(nextPlans); + lastSavedNotesRef.current = data.notes; + notesHydratedRef.current = true; setNewTodoText(''); setExpandedTodoIds(new Set()); } catch { @@ -103,6 +145,9 @@ export const ProjectNotesTodoPanel: React.FC = ({ toast.error('Failed to load project notes'); setNotes(''); setTodos([]); + setPlans([]); + lastSavedNotesRef.current = ''; + notesHydratedRef.current = true; } } finally { if (!cancelled) { @@ -114,12 +159,53 @@ export const ProjectNotesTodoPanel: React.FC = ({ return () => { cancelled = true; }; + }, [contextReloadTick, projectRef]); + + React.useEffect(() => { + if (!projectRef) { + return; + } + + const handleProjectContextRefresh = (event: Event) => { + const detail = (event as CustomEvent<{ projectId?: string }>).detail; + if (detail?.projectId && detail.projectId !== projectRef.id) { + return; + } + setContextReloadTick((previous) => previous + 1); + }; + + window.addEventListener('openchamber:project-plan-saved', handleProjectContextRefresh); + window.addEventListener('openchamber:project-notes-updated', handleProjectContextRefresh); + return () => { + window.removeEventListener('openchamber:project-plan-saved', handleProjectContextRefresh); + window.removeEventListener('openchamber:project-notes-updated', handleProjectContextRefresh); + }; }, [projectRef]); const handleNotesBlur = React.useCallback(() => { + lastSavedNotesRef.current = notes; void persistProjectData(notes, todos); }, [notes, persistProjectData, todos]); + React.useEffect(() => { + if (!projectRef || !notesHydratedRef.current) { + return; + } + + if (notes === lastSavedNotesRef.current) { + return; + } + + const timer = window.setTimeout(() => { + lastSavedNotesRef.current = notes; + void persistProjectData(notes, todos); + }, 400); + + return () => { + window.clearTimeout(timer); + }; + }, [notes, persistProjectData, projectRef, todos]); + const handleAddTodo = React.useCallback(() => { const trimmed = newTodoText.trim(); if (!trimmed) { @@ -188,19 +274,13 @@ export const ProjectNotesTodoPanel: React.FC = ({ }, [setActiveMainTab, setSessionSwitcherOpen]); const handleSendToNewSession = React.useCallback( - (todoText: string) => { - if (!projectRef) { + (todoId: string, todoText: string) => { + if (!projectRef || sendingTodoId) { return; } - routeToChat(); - openNewSessionDraft({ - directoryOverride: projectRef.path, - initialPrompt: todoText, - }); - toast.success('Todo sent to new session'); - onActionComplete?.(); + setPendingSendTarget({ kind: 'session', todoId, todoText }); }, - [onActionComplete, openNewSessionDraft, projectRef, routeToChat] + [projectRef, sendingTodoId] ); const handleSendToCurrentSession = React.useCallback( @@ -219,28 +299,127 @@ export const ProjectNotesTodoPanel: React.FC = ({ ); const handleSendToNewWorktreeSession = React.useCallback( - async (todoId: string, todoText: string) => { - if (!projectRef) { + (todoId: string, todoText: string) => { + if (!projectRef || sendingTodoId) { return; } if (!canCreateWorktree) { toast.error('Worktree actions are only available for Git repositories'); return; } - setSendingTodoId(todoId); + setPendingSendTarget({ kind: 'worktree', todoId, todoText }); + }, + [canCreateWorktree, projectRef, sendingTodoId] + ); + + const handleConfirmSend = React.useCallback( + async (execution: TodoSendExecution) => { + if (!projectRef || !pendingSendTarget) { + return; + } + + const visiblePrompt = await renderMagicPrompt('plan.todo.visible', { + todo_text: pendingSendTarget.todoText, + }); + const instructionsText = await renderMagicPrompt('plan.todo.instructions', { + todo_text: pendingSendTarget.todoText, + }); + const syntheticParts = [{ synthetic: true as const, text: instructionsText }]; + + setIsSendDialogSubmitting(true); + setSendingTodoId(pendingSendTarget.todoId); + try { routeToChat(); - const newWorktreePath = await createWorktreeDraft({ initialPrompt: todoText }); - if (!newWorktreePath) { + + let sessionId: string | null = null; + let directoryHint: string | null = projectRef.path; + + if (pendingSendTarget.kind === 'worktree') { + if (!canCreateWorktree) { + toast.error('Worktree actions are only available for Git repositories'); + return; + } + const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName()); + if (!created?.id) { + return; + } + sessionId = created.id; + directoryHint = null; + } else { + const session = await createSession(undefined, projectRef.path, null); + if (!session?.id) { + toast.error('Failed to create session'); + return; + } + sessionId = session.id; + directoryHint = session.directory ?? projectRef.path; + initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents ?? []); + } + + if (!sessionId) { return; } - toast.success('Todo sent to new worktree session'); + + const selectionState = useSelectionStore.getState(); + selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID); + if (execution.agent.trim()) { + selectionState.saveSessionAgentSelection(sessionId, execution.agent); + selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID); + selectionState.saveAgentModelVariantForSession( + sessionId, + execution.agent, + execution.providerID, + execution.modelID, + execution.variant || undefined, + ); + } + + setCurrentSession(sessionId, directoryHint); + await sendMessage( + visiblePrompt, + execution.providerID, + execution.modelID, + execution.agent.trim() || undefined, + undefined, + undefined, + syntheticParts, + execution.variant || undefined, + ); + + toast.success( + pendingSendTarget.kind === 'worktree' + ? 'Todo sent to new worktree session' + : 'Todo sent to new session' + ); + setPendingSendTarget(null); onActionComplete?.(); + } catch (error) { + const description = error instanceof Error ? error.message : undefined; + toast.error('Failed to send todo', description ? { description } : undefined); } finally { + setIsSendDialogSubmitting(false); setSendingTodoId(null); } }, - [canCreateWorktree, onActionComplete, projectRef, routeToChat] + [canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession] + ); + + const handleOpenPlan = React.useCallback( + (plan: ProjectPlanListItem) => { + const projectPath = projectRef?.path?.trim(); + const panelDirectory = currentDirectory?.trim() || projectPath; + if (!panelDirectory) { + return; + } + openContextPanelTab(panelDirectory, { + mode: 'plan', + targetPath: plan.path, + dedupeKey: plan.path, + label: plan.title, + }); + }, + [currentDirectory, openContextPanelTab, projectRef] ); if (!projectRef) { @@ -367,7 +546,7 @@ export const ProjectNotesTodoPanel: React.FC = ({ handleSendToCurrentSession(todo.text)}> Send to current session - handleSendToNewSession(todo.text)}> + handleSendToNewSession(todo.id, todo.text)}> Send to new session = ({ )} + +
+
+
+

Plans

+ {plans.length} file{plans.length === 1 ? '' : 's'} +
+
+ +
+ {plans.length === 0 ? ( +

No saved plans yet.

+ ) : ( +
    + {plans.map((plan) => ( +
  • + +
  • + ))} +
+ )} +
+
+ + { + if (!open && !isSendDialogSubmitting) { + setPendingSendTarget(null); + } + }} + target={pendingSendTarget?.kind ?? 'session'} + projectDirectory={projectRef?.path ?? null} + submitting={isSendDialogSubmitting} + onConfirm={handleConfirmSend} + /> ); }; diff --git a/packages/ui/src/components/session/TodoSendDialog.tsx b/packages/ui/src/components/session/TodoSendDialog.tsx new file mode 100644 index 00000000..6e4d448e --- /dev/null +++ b/packages/ui/src/components/session/TodoSendDialog.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ModelSelector } from '@/components/sections/agents/ModelSelector'; +import { AgentSelector } from '@/components/sections/commands/AgentSelector'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useAgentsStore } from '@/stores/useAgentsStore'; +import { isPrimaryMode } from '@/components/chat/mobileControlsUtils'; +import { cn } from '@/lib/utils'; +import { RiArrowDownSLine } from '@remixicon/react'; + +type TodoSendTarget = 'session' | 'worktree'; + +export type TodoSendExecution = { + providerID: string; + modelID: string; + variant: string; + agent: string; +}; + +type TodoSendDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + target: TodoSendTarget; + projectDirectory: string | null; + submitting?: boolean; + onConfirm: (execution: TodoSendExecution) => Promise | void; +}; + +const getInitialExecution = (params: { + providerID: string; + modelID: string; + variant: string; + agent: string; +}): TodoSendExecution => ({ + providerID: params.providerID, + modelID: params.modelID, + variant: params.variant, + agent: params.agent, +}); + +type ThinkingPillProps = { + value: string; + options: string[]; + disabled?: boolean; + onChange: (value: string) => void; +}; + +const ThinkingPill = ({ value, options, disabled, onChange }: ThinkingPillProps) => { + const label = value || 'Default'; + + const trigger = ( +
+ {label} + +
+ ); + + if (disabled) return trigger; + + return ( + + {trigger} + + onChange('')}> + Default + + {options.map((option) => ( + onChange(option)} + > + + {option} + + + ))} + + + ); +}; + +export function TodoSendDialog(props: TodoSendDialogProps) { + const { open, onOpenChange, target, projectDirectory, submitting = false, onConfirm } = props; + + const loadProviders = useConfigStore((state) => state.loadProviders); + const loadConfigAgents = useConfigStore((state) => state.loadAgents); + const loadAgentsStoreAgents = useAgentsStore((state) => state.loadAgents); + const providers = useConfigStore((state) => state.providers); + const currentProviderID = useConfigStore((state) => state.currentProviderId); + const currentModelID = useConfigStore((state) => state.currentModelId); + const currentVariant = useConfigStore((state) => state.currentVariant || ''); + const currentAgentName = useConfigStore((state) => state.currentAgentName || ''); + + const [execution, setExecution] = React.useState(() => getInitialExecution({ + providerID: currentProviderID, + modelID: currentModelID, + variant: currentVariant, + agent: currentAgentName, + })); + + React.useEffect(() => { + if (!open) return; + void loadProviders({ directory: projectDirectory }); + void loadConfigAgents({ directory: projectDirectory }); + void loadAgentsStoreAgents(); + }, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]); + + React.useEffect(() => { + if (!open) return; + setExecution(getInitialExecution({ + providerID: currentProviderID, + modelID: currentModelID, + variant: currentVariant, + agent: currentAgentName, + })); + }, [open, currentProviderID, currentModelID, currentVariant, currentAgentName]); + + React.useEffect(() => { + if (!open || providers.length === 0) return; + + const provider = providers.find((item) => item.id === execution.providerID) ?? providers[0]; + const models = Array.isArray(provider?.models) ? provider.models : []; + const hasModel = models.some((item) => item.id === execution.modelID); + const fallbackModelID = models[0]?.id ?? ''; + + if (provider?.id === execution.providerID && hasModel) return; + + setExecution((prev) => ({ + ...prev, + providerID: provider?.id ?? '', + modelID: hasModel ? prev.modelID : fallbackModelID, + variant: '', + })); + }, [open, providers, execution.providerID, execution.modelID]); + + const agentFilter = React.useCallback((agent: { mode?: string }) => isPrimaryMode(agent.mode), []); + + const variantOptions = React.useMemo(() => { + const provider = providers.find((item) => item.id === execution.providerID); + const model = provider?.models?.find((item) => item.id === execution.modelID) as { variants?: Record } | undefined; + return model?.variants ? Object.keys(model.variants) : []; + }, [providers, execution.providerID, execution.modelID]); + + const hasVariantOptions = variantOptions.length > 0; + + React.useEffect(() => { + if (hasVariantOptions || !execution.variant) return; + setExecution((prev) => ({ ...prev, variant: '' })); + }, [hasVariantOptions, execution.variant]); + + const canConfirm = execution.providerID.trim().length > 0 && execution.modelID.trim().length > 0; + + const handleSubmit = React.useCallback(() => { + if (!canConfirm || submitting) return; + void onConfirm(execution); + }, [canConfirm, submitting, onConfirm, execution]); + + React.useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + handleSubmit(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open, handleSubmit]); + + const title = target === 'worktree' ? 'Send to new worktree' : 'Send to new session'; + + return ( + { if (!submitting) onOpenChange(nextOpen); }}> + + + {title} + + +
+ { + setExecution((prev) => ({ ...prev, providerID, modelID, variant: '' })); + }} + /> + setExecution((prev) => ({ ...prev, variant }))} + /> + setExecution((prev) => ({ ...prev, agent }))} + /> +
+ +
+ + +
+
+
+ ); +}