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).
This commit is contained in:
@@ -11,18 +11,26 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
getProjectNotesAndTodos,
|
getProjectContextData,
|
||||||
OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH,
|
OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH,
|
||||||
|
readProjectPlanFile,
|
||||||
OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH,
|
OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH,
|
||||||
saveProjectNotesAndTodos,
|
saveProjectNotesAndTodos,
|
||||||
|
type OpenChamberProjectPlanFileLink,
|
||||||
type OpenChamberProjectTodoItem,
|
type OpenChamberProjectTodoItem,
|
||||||
type ProjectRef,
|
type ProjectRef,
|
||||||
} from '@/lib/openchamberConfig';
|
} from '@/lib/openchamberConfig';
|
||||||
|
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useInputStore } from '@/sync/input-store';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||||
|
import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog';
|
||||||
|
|
||||||
interface ProjectNotesTodoPanelProps {
|
interface ProjectNotesTodoPanelProps {
|
||||||
projectRef: ProjectRef | null;
|
projectRef: ProjectRef | null;
|
||||||
@@ -32,6 +40,24 @@ interface ProjectNotesTodoPanelProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingSendTarget = {
|
||||||
|
kind: 'session' | 'worktree';
|
||||||
|
todoId: string;
|
||||||
|
todoText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectPlanListItem = OpenChamberProjectPlanFileLink & {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPlanListItem = async (plan: OpenChamberProjectPlanFileLink): Promise<ProjectPlanListItem> => {
|
||||||
|
const file = await readProjectPlanFile(plan.path);
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
title: file?.title || plan.path.split('/').pop() || 'Plan',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const createTodoId = (): string => {
|
const createTodoId = (): string => {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -52,10 +78,21 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
const [newTodoText, setNewTodoText] = React.useState('');
|
const [newTodoText, setNewTodoText] = React.useState('');
|
||||||
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
|
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
|
||||||
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
|
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
|
||||||
|
const [plans, setPlans] = React.useState<ProjectPlanListItem[]>([]);
|
||||||
|
const [pendingSendTarget, setPendingSendTarget] = React.useState<PendingSendTarget | null>(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 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 setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||||
|
|
||||||
@@ -80,6 +117,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
if (!projectRef) {
|
if (!projectRef) {
|
||||||
setNotes('');
|
setNotes('');
|
||||||
setTodos([]);
|
setTodos([]);
|
||||||
|
setPlans([]);
|
||||||
setNewTodoText('');
|
setNewTodoText('');
|
||||||
setExpandedTodoIds(new Set());
|
setExpandedTodoIds(new Set());
|
||||||
return;
|
return;
|
||||||
@@ -90,12 +128,16 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getProjectNotesAndTodos(projectRef);
|
const data = await getProjectContextData(projectRef);
|
||||||
|
const nextPlans = await Promise.all(data.plans.map((plan) => toPlanListItem(plan)));
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setNotes(data.notes);
|
setNotes(data.notes);
|
||||||
setTodos(data.todos);
|
setTodos(data.todos);
|
||||||
|
setPlans(nextPlans);
|
||||||
|
lastSavedNotesRef.current = data.notes;
|
||||||
|
notesHydratedRef.current = true;
|
||||||
setNewTodoText('');
|
setNewTodoText('');
|
||||||
setExpandedTodoIds(new Set());
|
setExpandedTodoIds(new Set());
|
||||||
} catch {
|
} catch {
|
||||||
@@ -103,6 +145,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
toast.error('Failed to load project notes');
|
toast.error('Failed to load project notes');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
setTodos([]);
|
setTodos([]);
|
||||||
|
setPlans([]);
|
||||||
|
lastSavedNotesRef.current = '';
|
||||||
|
notesHydratedRef.current = true;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -114,12 +159,53 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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]);
|
}, [projectRef]);
|
||||||
|
|
||||||
const handleNotesBlur = React.useCallback(() => {
|
const handleNotesBlur = React.useCallback(() => {
|
||||||
|
lastSavedNotesRef.current = notes;
|
||||||
void persistProjectData(notes, todos);
|
void persistProjectData(notes, todos);
|
||||||
}, [notes, persistProjectData, 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 handleAddTodo = React.useCallback(() => {
|
||||||
const trimmed = newTodoText.trim();
|
const trimmed = newTodoText.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -188,19 +274,13 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
}, [setActiveMainTab, setSessionSwitcherOpen]);
|
}, [setActiveMainTab, setSessionSwitcherOpen]);
|
||||||
|
|
||||||
const handleSendToNewSession = React.useCallback(
|
const handleSendToNewSession = React.useCallback(
|
||||||
(todoText: string) => {
|
(todoId: string, todoText: string) => {
|
||||||
if (!projectRef) {
|
if (!projectRef || sendingTodoId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
routeToChat();
|
setPendingSendTarget({ kind: 'session', todoId, todoText });
|
||||||
openNewSessionDraft({
|
|
||||||
directoryOverride: projectRef.path,
|
|
||||||
initialPrompt: todoText,
|
|
||||||
});
|
|
||||||
toast.success('Todo sent to new session');
|
|
||||||
onActionComplete?.();
|
|
||||||
},
|
},
|
||||||
[onActionComplete, openNewSessionDraft, projectRef, routeToChat]
|
[projectRef, sendingTodoId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSendToCurrentSession = React.useCallback(
|
const handleSendToCurrentSession = React.useCallback(
|
||||||
@@ -219,28 +299,127 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSendToNewWorktreeSession = React.useCallback(
|
const handleSendToNewWorktreeSession = React.useCallback(
|
||||||
async (todoId: string, todoText: string) => {
|
(todoId: string, todoText: string) => {
|
||||||
if (!projectRef) {
|
if (!projectRef || sendingTodoId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!canCreateWorktree) {
|
if (!canCreateWorktree) {
|
||||||
toast.error('Worktree actions are only available for Git repositories');
|
toast.error('Worktree actions are only available for Git repositories');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSendingTodoId(todoId);
|
setPendingSendTarget({ kind: 'worktree', todoId, todoText });
|
||||||
try {
|
},
|
||||||
routeToChat();
|
[canCreateWorktree, projectRef, sendingTodoId]
|
||||||
const newWorktreePath = await createWorktreeDraft({ initialPrompt: todoText });
|
);
|
||||||
if (!newWorktreePath) {
|
|
||||||
|
const handleConfirmSend = React.useCallback(
|
||||||
|
async (execution: TodoSendExecution) => {
|
||||||
|
if (!projectRef || !pendingSendTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Todo sent to new worktree session');
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.();
|
onActionComplete?.();
|
||||||
|
} catch (error) {
|
||||||
|
const description = error instanceof Error ? error.message : undefined;
|
||||||
|
toast.error('Failed to send todo', description ? { description } : undefined);
|
||||||
} finally {
|
} finally {
|
||||||
|
setIsSendDialogSubmitting(false);
|
||||||
setSendingTodoId(null);
|
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) {
|
if (!projectRef) {
|
||||||
@@ -367,7 +546,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
|
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
|
||||||
Send to current session
|
Send to current session
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.text)}>
|
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.id, todo.text)}>
|
||||||
Send to new session
|
Send to new session
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -386,6 +565,51 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="typography-ui-label font-semibold text-foreground">Plans</h3>
|
||||||
|
<span className="typography-meta text-muted-foreground">{plans.length} file{plans.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
|
||||||
|
{plans.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 typography-meta text-muted-foreground">No saved plans yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border/50">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<li key={plan.id} className="px-2.5 py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenPlan(plan)}
|
||||||
|
className="flex w-full min-w-0 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate typography-ui-label text-foreground">{plan.title}</span>
|
||||||
|
<span className="flex-shrink-0 typography-micro text-muted-foreground">
|
||||||
|
{new Date(plan.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TodoSendDialog
|
||||||
|
open={pendingSendTarget !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isSendDialogSubmitting) {
|
||||||
|
setPendingSendTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
target={pendingSendTarget?.kind ?? 'session'}
|
||||||
|
projectDirectory={projectRef?.path ?? null}
|
||||||
|
submitting={isSendDialogSubmitting}
|
||||||
|
onConfirm={handleConfirmSend}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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> | 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 = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-6 w-fit items-center gap-1.5 rounded-lg border border-border/20 bg-interactive-selection/20 px-2',
|
||||||
|
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-interactive-hover/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="typography-micro whitespace-nowrap font-medium capitalize">{label}</span>
|
||||||
|
<RiArrowDownSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disabled) return trigger;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="max-w-[220px]">
|
||||||
|
<DropdownMenuItem className="typography-meta" onSelect={() => onChange('')}>
|
||||||
|
<span className={cn('font-medium', !value && 'text-primary')}>Default</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{options.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option}
|
||||||
|
className="typography-meta"
|
||||||
|
onSelect={() => onChange(option)}
|
||||||
|
>
|
||||||
|
<span className={cn('font-medium capitalize', value === option && 'text-primary')}>
|
||||||
|
{option}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<TodoSendExecution>(() => 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<string, unknown> } | 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 (
|
||||||
|
<Dialog open={open} onOpenChange={(nextOpen) => { if (!submitting) onOpenChange(nextOpen); }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<ModelSelector
|
||||||
|
providerId={execution.providerID}
|
||||||
|
modelId={execution.modelID}
|
||||||
|
onChange={(providerID, modelID) => {
|
||||||
|
setExecution((prev) => ({ ...prev, providerID, modelID, variant: '' }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ThinkingPill
|
||||||
|
value={execution.variant}
|
||||||
|
options={variantOptions}
|
||||||
|
disabled={!hasVariantOptions}
|
||||||
|
onChange={(variant) => setExecution((prev) => ({ ...prev, variant }))}
|
||||||
|
/>
|
||||||
|
<AgentSelector
|
||||||
|
agentName={execution.agent}
|
||||||
|
filter={agentFilter}
|
||||||
|
onChange={(agent) => setExecution((prev) => ({ ...prev, agent }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSubmit} disabled={!canConfirm || submitting}>
|
||||||
|
{submitting ? 'Sending' : 'Send'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user