Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions src/components/chat/hooks/useChatComposerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ import { useSlashCommands } from './useSlashCommands';
import { useWorkspaceTarget, type WorkspaceCandidate } from './useWorkspaceTarget';
import { newQueuedDraftId, settleRetainedComposerSteer, useDurableComposerDraft } from './useDurableComposerDraft';

interface UseChatComposerStateArgs { draftRepository?: ComposerDraftRepository; executionCwd?: string | null; selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; gjcModel: string; reasoningEffort?: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record<string, unknown> | null; sendMessage: (message: unknown) => boolean | void; sendByCtrlEnter?: boolean; onSessionProcessing?: MarkSessionProcessing; onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onCommandGateChange?: (gate: PendingCommandGate | null) => void; onShowSettings?: () => void; onLogin?: (providerId?: string) => void; scrollToBottom: () => void; addMessage: (msg: ChatMessage) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; }
interface UseChatComposerStateArgs { draftRepository?: ComposerDraftRepository; executionCwd?: string | null; selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; gjcModel: string; reasoningEffort?: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record<string, unknown> | null; sendMessage: (message: unknown) => boolean | void; sendByCtrlEnter?: boolean; onSessionProcessing?: MarkSessionProcessing; onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onCommandGateChange?: (gate: PendingCommandGate | null) => void; onShowSettings?: () => void; onLogin?: (providerId?: string) => void;
/**
* What the run-location picker shows before the user touches it.
*
* The caller owns this because the answer depends on whether the project is
* a git repository, which the composer never asks about. An untouched picker
* follows it; an explicit choice outranks it until the project changes.
*/
defaultUseWorktree?: boolean; scrollToBottom: () => void; addMessage: (msg: ChatMessage) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; }
interface MentionableFile { name: string; path: string; }
export type ModelCommandData = { current?: { provider?: string; providerLabel?: string; model?: string }; available?: Partial<Record<LLMProvider, string[]>>; availableModels?: string[]; availableOptions?: Array<{ value: string; label?: string; description?: string }>; defaultModel?: string; cache?: ProviderModelsCacheInfo; };
export type CostCommandData = { tokenUsage?: { used?: number; total?: number }; tokenBreakdown?: { input?: number; output?: number }; provider?: string; model?: string; };
Expand All @@ -47,7 +55,7 @@ const resetBox = (setInput: (value: string) => void, value: MutableRefObject<str

export function useChatComposerState(args: UseChatComposerStateArgs) {
const { t } = useTranslation('chat');
const { executionCwd, selectedProject, selectedSession, currentSessionId, gjcModel, reasoningEffort = 'default', isLoading, canAbortSession, tokenBudget, sendMessage, sendByCtrlEnter, onSessionProcessing, onSessionEstablished, onInputFocusChange, onCommandGateChange, onShowSettings, onLogin, scrollToBottom, addMessage, setIsUserScrolledUp, setPendingPermissionRequests } = args;
const { executionCwd, selectedProject, selectedSession, currentSessionId, gjcModel, reasoningEffort = 'default', isLoading, canAbortSession, tokenBudget, sendMessage, sendByCtrlEnter, onSessionProcessing, onSessionEstablished, onInputFocusChange, onCommandGateChange, onShowSettings, onLogin, scrollToBottom, addMessage, setIsUserScrolledUp, setPendingPermissionRequests, defaultUseWorktree = false } = args;
const projectId = selectedProject?.projectId;
const conversation = selectedSession?.id || currentSessionId || null;
const drafts = useDurableComposerDraft(projectId, conversation, args.draftRepository);
Expand All @@ -57,6 +65,12 @@ export function useChatComposerState(args: UseChatComposerStateArgs) {
const [isTextareaExpanded, setExpanded] = useState(false);
const [isInputFocused, setFocused] = useState(false);
const [commandModalPayload, setModal] = useState<CommandModalPayload | null>(null);
// `null` means "not chosen", which is different from "chose Project": only
// the former follows `defaultUseWorktree` when it resolves, and the choice is
// dropped when the project changes so one project's answer is not carried
// into another that may not even be a repository.
const [worktreeChoice, setWorktreeChoice] = useState<boolean | null>(null);
const useWorktree = worktreeChoice ?? defaultUseWorktree;
const [modelPickerTrigger, setModelPickerTrigger] = useState(0);
const [pendingCommandGate, setGateState] = useState<PendingCommandGate | null>(null);
const [queuePulse, setQueuePulse] = useState(0);
Expand Down Expand Up @@ -110,6 +124,11 @@ export function useChatComposerState(args: UseChatComposerStateArgs) {
if (submissionOwner.current === owner) submissionOwner.current = null;
};
}, [conversation, projectId]);
// A run location is chosen per project. Carrying one project's answer into
// the next would silently pick a location for a repository the user never
// looked at - and for a project that is not a repository at all, the
// worktree route would simply fail.
useEffect(() => { setWorktreeChoice(null); }, [projectId]);
useEffect(() => { inputRef.current = input; }, [input]);
useEffect(() => { liveImages.current = attachedImages; }, [attachedImages]);

Expand Down Expand Up @@ -154,15 +173,18 @@ export function useChatComposerState(args: UseChatComposerStateArgs) {
if (!isCurrent()) return null;
const project = target ? await descend(target) : selectedProject;
if (!isCurrent()) return null;
const response = await authenticatedFetch('/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) });
// The worktree route allocates the session and its managed checkout in one
// transaction; the ordinary route binds the session to the project itself.
// Both return the same `sessionId`, so nothing downstream branches on this.
const response = await authenticatedFetch(useWorktree ? '/api/providers/worktree-sessions' : '/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(typeof body.error === 'string' ? body.error : body.error?.message ?? `Failed to create session (${response.status})`);
}
id = (await response.json())?.data?.sessionId || null;
if (!id) throw new Error('no session id returned.');
return { id, context: { provider: 'gjc', project: project!, summary } };
}, [currentSessionId, descend, resolveForSend, selectedProject, selectedSession]);
}, [currentSessionId, descend, resolveForSend, selectedProject, selectedSession, useWorktree]);

const handleSubmit = useCallback(async (event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>, queued?: QueuedDraft) => {
event.preventDefault(); const text = queued?.content ?? inputRef.current; if (!text.trim() || !selectedProject) return;
Expand Down Expand Up @@ -415,5 +437,5 @@ export function useChatComposerState(args: UseChatComposerStateArgs) {
} finally { finishOperation(); }
}, [sendMessage, setPendingPermissionRequests]);
const handleInputFocusChange = useCallback((focused: boolean) => { setFocused(focused); onInputFocusChange?.(focused); }, [onInputFocusChange]);
return { composerFrozen, draftPersistence, draftReady, retryDraftPersistence, input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, attachmentNotice, dismissAttachmentNotice: () => setAttachmentNotice(null), getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget };
return { useWorktree, setUseWorktree: setWorktreeChoice, composerFrozen, draftPersistence, draftReady, retryDraftPersistence, input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, attachmentNotice, dismissAttachmentNotice: () => setAttachmentNotice(null), getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget };
}
5 changes: 5 additions & 0 deletions src/components/chat/view/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ interface ChatComposerProps {
isDragActive: boolean;
/** Model pinned to this session, if any; outranks the last-run model. */
sessionPinnedModel?: string | null;
/** The run-location control, rendered first among the composer tools. */
sessionLocationControl?: ReactNode;
queuedDrafts: QueuedDraft[];
onEditQueuedDraft: (index: number) => void;
onDeleteQueuedDraft: (index: number) => void;
Expand Down Expand Up @@ -166,6 +168,7 @@ export default function ChatComposer({
onSteer,
isDragActive,
sessionPinnedModel,
sessionLocationControl,
queuedDrafts,
onEditQueuedDraft,
onDeleteQueuedDraft,
Expand Down Expand Up @@ -484,6 +487,8 @@ export default function ChatComposer({
can wrap separately when a split pane leaves too little room.
*/}
<PromptInputTools className="min-w-32 flex-1 basis-0 flex-wrap gap-y-1">
{sessionLocationControl}

<PromptInputButton
tooltip={{ content: t('input.attachImages') }}
onClick={openImagePicker}
Expand Down
19 changes: 18 additions & 1 deletion src/components/chat/view/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useLegacySkipPermissionsMigration, useProjectPermissions } from '../../
import type { ProjectSession } from '../../../types/app';
import { useChatComposerState } from '../hooks/useChatComposerState';
import { useSessionLocation } from '../hooks/useSessionLocation';
import { useProjectGitSummary } from '../../workspace/hooks/useProjectGitSummary';
import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatSessionState } from '../hooks/useChatSessionState';
Expand All @@ -21,6 +22,7 @@ import { deriveLiveActivity } from '../utils/toolActivity';
import OAuthLoginDialog from '../OAuthLoginDialog';
import { useGoalControls } from '../hooks/useGoalControls';

import SessionWorktreePicker from './SessionWorktreePicker';
import GoalControls from './GoalControls';
import ChatComposer from './ChatComposer';
import ChatMessagesPane from './ChatMessagesPane';
Expand Down Expand Up @@ -87,7 +89,15 @@ function ChatInterface({
});

const { setCurrentSessionId } = session;
const sessionLocation = useSessionLocation(selectedSession?.id ?? session.currentSessionId);
const locationSessionId = selectedSession?.id ?? session.currentSessionId;
const sessionLocation = useSessionLocation(locationSessionId);
// A managed worktree is a git worktree, so it exists only for a repository.
// The summary is the same query the Environment rail already runs, and it is
// the only thing here that knows the answer: until it does, or when it says
// no, the picker stays on the project so the worktree route is never called
// where it would simply fail.
const projectGit = useProjectGitSummary(selectedProject?.projectId, Boolean(selectedProject?.projectId), locationSessionId ?? undefined, selectedProject?.fullPath);
const projectIsRepository = projectGit.state.kind === 'ready';
const establishSession = useCallback<NonNullable<ChatInterfaceProps['onSessionEstablished']>>((id, context) => {
setCurrentSessionId(id);
onSessionEstablished?.(id, context);
Expand All @@ -96,6 +106,10 @@ function ChatInterface({

const composer = useChatComposerState({
executionCwd: sessionLocation.data?.cwd,
// An unattended run commits, pushes and switches branches on whatever
// checkout it was given, so a repository session starts isolated and the
// user opts back into the shared checkout rather than out of it.
defaultUseWorktree: projectIsRepository,
selectedProject,
selectedSession,
currentSessionId: session.currentSessionId,
Expand Down Expand Up @@ -247,6 +261,9 @@ function ChatInterface({
onSteer={composer.handleSteer}
isDragActive={composer.isDragActive}
sessionPinnedModel={sessionPinnedModel}
sessionLocationControl={projectIsRepository || locationSessionId
? <SessionWorktreePicker value={composer.useWorktree} onChange={composer.setUseWorktree} sessionId={locationSessionId} location={sessionLocation.data} disabled={session.isProcessing} />
: null}
queuedDrafts={composer.queuedDrafts}
onEditQueuedDraft={composer.editQueuedDraft}
onDeleteQueuedDraft={composer.deleteQueuedDraft}
Expand Down
Loading