Merge upstream main into feat/subagent-cost-rollup
This commit is contained in:
@@ -18,6 +18,7 @@ import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { useScrollShadow } from '@/components/ui/useScrollShadow';
|
||||
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
@@ -645,6 +646,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
suspendPartUpdatesForMessageId: streamingMessageId,
|
||||
});
|
||||
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||
const authSessionExpired = useAuthSessionStore((store) => store.state !== 'ok');
|
||||
const wasAuthExpiredRef = React.useRef(false);
|
||||
const sessionMessageLoadState = useSessionMessageLoadState(
|
||||
currentSessionId ?? '',
|
||||
effectiveSessionDirectory,
|
||||
@@ -1170,6 +1173,23 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
}, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]);
|
||||
|
||||
// A load that failed while the session was expired retries itself the
|
||||
// moment the re-login lands — the error screen should never outlive its
|
||||
// cause.
|
||||
React.useEffect(() => {
|
||||
if (authSessionExpired) {
|
||||
wasAuthExpiredRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (wasAuthExpiredRef.current) {
|
||||
wasAuthExpiredRef.current = false;
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
retrySessionLoad();
|
||||
}
|
||||
}
|
||||
}, [authSessionExpired, retrySessionLoad, sessionMessageLoadState.status]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
|
||||
@@ -1298,10 +1318,20 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
<Icon name="error-warning" className="size-4" />
|
||||
</div>
|
||||
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">
|
||||
{authSessionExpired
|
||||
? t('chat.container.sessionLoadError.authDescription')
|
||||
: t('chat.container.sessionLoadError.description')}
|
||||
</p>
|
||||
{authSessionExpired ? (
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => useAuthSessionStore.getState().markReauthenticating()}>
|
||||
{t('sessionAuth.expired.loginAction')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -77,6 +77,8 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { togglePermissionAutoAccept } from './permissionAutoAccept';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { extractGitChangedFiles } from './changedFiles';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -417,7 +419,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
|
||||
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
|
||||
const clearGitDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
||||
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
|
||||
const [attachmentPreview, setAttachmentPreview] = React.useState<ToolPopupContent>({
|
||||
@@ -695,7 +696,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
attachments,
|
||||
};
|
||||
}, [resolveInlineFileMention]);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
// Issue linking state
|
||||
@@ -964,6 +964,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const queuedMessageId = options?.queuedMessageId;
|
||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||
const capturedTarget = messageQueueTarget;
|
||||
// An expired session cannot deliver anything: keep the prompt in the
|
||||
// composer and point at the login banner instead of burning the send
|
||||
// on a guaranteed 401.
|
||||
if (useAuthSessionStore.getState().state !== 'ok') {
|
||||
toast.error(t('sessionAuth.expired.sendBlocked'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the draft and current-session identity before the first
|
||||
// async gap so a later sidebar selection cannot reroute the send.
|
||||
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;
|
||||
@@ -1382,10 +1390,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
console.error('Message send failed:', rawMessage || error);
|
||||
restoreConsumedDrafts();
|
||||
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
|
||||
setMessage(inputSnapshot.message);
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
// A failed send returns the typed prompt no matter WHY it failed —
|
||||
// auth, network, server, anything. Losing a long prompt to a toast
|
||||
// is the one outcome this handler must never produce.
|
||||
if (inputSnapshot.message) {
|
||||
if (currentChatDraftIdentityRef.current !== chatDraftIdentity) {
|
||||
// The user switched sessions mid-send: restore into that
|
||||
// session's persisted draft, not the visible composer.
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
} else {
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (!currentInput || currentInput === inputSnapshot.message) {
|
||||
setMessage(inputSnapshot.message);
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
} else {
|
||||
// New typing already lives in the composer; the failed
|
||||
// prompt joins it instead of clobbering either text.
|
||||
useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSoftNetworkError =
|
||||
@@ -1696,29 +1719,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
containerRef: dropZoneRef,
|
||||
});
|
||||
|
||||
const startAbortIndicator = React.useCallback(() => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
abortTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setShowAbortStatus(true);
|
||||
|
||||
abortTimeoutRef.current = setTimeout(() => {
|
||||
setShowAbortStatus(false);
|
||||
abortTimeoutRef.current = null;
|
||||
}, 1800);
|
||||
}, []);
|
||||
|
||||
const handleAbort = React.useCallback(() => {
|
||||
clearAbortPrompt();
|
||||
startAbortIndicator();
|
||||
|
||||
// btw mode: the stop button stops the fork's turn, not the main
|
||||
// session's.
|
||||
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
|
||||
void abortCurrentOperation(abortTarget || undefined);
|
||||
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]);
|
||||
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive]);
|
||||
|
||||
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
|
||||
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
|
||||
@@ -2562,31 +2571,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
||||
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
||||
startAbortIndicator();
|
||||
if (currentSessionId) {
|
||||
acknowledgeSessionAbort(currentSessionId);
|
||||
}
|
||||
}
|
||||
prevWasAbortedRef.current = pendingAbortBanner;
|
||||
}, [
|
||||
abortPromptSessionId,
|
||||
acknowledgeSessionAbort,
|
||||
currentSessionId,
|
||||
showAbortStatus,
|
||||
startAbortIndicator,
|
||||
]);
|
||||
useKeybind('toggle_permission_auto_accept', () => {
|
||||
if (!isPermissionAutoAcceptInteractive) return false;
|
||||
handlePermissionAutoAcceptToggle();
|
||||
});
|
||||
|
||||
// Acknowledging the abort record is what lets the working chip resume for
|
||||
// the next run; the old "Aborted" banner that used to accompany it is gone.
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
abortTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const pendingAbort = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
||||
if (!prevWasAbortedRef.current && pendingAbort && currentSessionId) {
|
||||
acknowledgeSessionAbort(currentSessionId);
|
||||
}
|
||||
prevWasAbortedRef.current = pendingAbort;
|
||||
}, [abortPromptSessionId, acknowledgeSessionAbort, currentSessionId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2657,7 +2655,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
/>
|
||||
<MemoComposerStatusBar
|
||||
showAbortStatus={showAbortStatus}
|
||||
showTodos={composerStatusExtrasEnabled}
|
||||
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
|
||||
? null
|
||||
|
||||
@@ -457,13 +457,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}, [chatRenderMode, isMessageCompleted, isUser, visibleParts]);
|
||||
|
||||
|
||||
const assistantTextParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return [];
|
||||
}
|
||||
return visibleParts.filter((part) => part.type === 'text');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const toolParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return [];
|
||||
@@ -545,19 +538,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const shouldHideUserMessage = isUser && displayParts.length === 0;
|
||||
|
||||
// Message is considered to have an "open step" if info.finish is not yet present
|
||||
const hasOpenStep = typeof messageFinish !== 'string';
|
||||
|
||||
const shouldCoordinateRendering = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return false;
|
||||
}
|
||||
if (assistantTextParts.length === 0 || toolParts.length === 0) {
|
||||
return hasOpenStep;
|
||||
}
|
||||
return true;
|
||||
}, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
|
||||
|
||||
const themeVariant = currentTheme?.metadata.variant;
|
||||
const isDarkTheme = React.useMemo(() => {
|
||||
if (themeVariant) {
|
||||
|
||||
@@ -116,13 +116,11 @@ const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
|
||||
const EMPTY_TODOS: TodoItem[] = [];
|
||||
|
||||
interface ComposerStatusBarProps {
|
||||
showAbortStatus?: boolean;
|
||||
showTodos?: boolean;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
showAbortStatus,
|
||||
showTodos = true,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
@@ -186,7 +184,7 @@ export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
|
||||
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||
const hasLeftAccessory = Boolean(leftAccessory);
|
||||
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
|
||||
const hasContent = hasTodoContent || hasLeftAccessory;
|
||||
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
@@ -252,16 +250,7 @@ export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: abort status | pending-changes accessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true" />
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : leftAccessory ? (
|
||||
leftAccessory
|
||||
) : null}
|
||||
{leftAccessory ?? null}
|
||||
</div>
|
||||
|
||||
{/* Right: todos dropdown */}
|
||||
|
||||
@@ -1534,6 +1534,54 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return true;
|
||||
}, [allEntries.length]);
|
||||
|
||||
// A navigation scroll lands on estimates: an unmounted target teleports
|
||||
// to its estimated offset, and even a mounted one drifts when neighbours
|
||||
// finish measuring a frame later. This settle loop re-aligns the target to
|
||||
// the requested viewport position until the layout stops moving, and backs
|
||||
// off the moment the user touches the scroll.
|
||||
const settleNavigationTarget = React.useCallback((
|
||||
findElement: () => HTMLElement | null,
|
||||
desiredOffsetTop: number,
|
||||
) => {
|
||||
const container = resolveScrollContainer();
|
||||
if (!container || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
let frames = 0;
|
||||
let stable = 0;
|
||||
let cancelled = false;
|
||||
const cancelOnUserInput = () => {
|
||||
cancelled = true;
|
||||
container.removeEventListener('touchstart', cancelOnUserInput);
|
||||
container.removeEventListener('wheel', cancelOnUserInput);
|
||||
};
|
||||
container.addEventListener('touchstart', cancelOnUserInput, { passive: true });
|
||||
container.addEventListener('wheel', cancelOnUserInput, { passive: true });
|
||||
const step = () => {
|
||||
if (cancelled) return;
|
||||
const element = findElement();
|
||||
if (element) {
|
||||
const delta = element.getBoundingClientRect().top
|
||||
- container.getBoundingClientRect().top
|
||||
- desiredOffsetTop;
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
container.scrollTop += delta;
|
||||
stable = 0;
|
||||
} else {
|
||||
stable += 1;
|
||||
}
|
||||
}
|
||||
frames += 1;
|
||||
if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) {
|
||||
container.removeEventListener('touchstart', cancelOnUserInput);
|
||||
container.removeEventListener('wheel', cancelOnUserInput);
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(step);
|
||||
};
|
||||
window.requestAnimationFrame(step);
|
||||
}, [resolveScrollContainer]);
|
||||
|
||||
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) {
|
||||
@@ -1569,14 +1617,19 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
||||
const findTurnElement = () => container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
||||
const turnElement = findTurnElement();
|
||||
if (turnElement) {
|
||||
turnElement.scrollIntoView({ behavior, block: 'start' });
|
||||
if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return scrollHistoryIndexIntoView(index);
|
||||
if (!scrollHistoryIndexIntoView(index)) {
|
||||
return false;
|
||||
}
|
||||
if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0);
|
||||
return true;
|
||||
},
|
||||
|
||||
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
|
||||
@@ -1586,8 +1639,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
return scrollMessageElementIntoView(messageId, behavior)
|
||||
const didScroll = scrollMessageElementIntoView(messageId, behavior)
|
||||
|| scrollHistoryIndexIntoView(index);
|
||||
if (didScroll && behavior !== 'smooth') {
|
||||
settleNavigationTarget(() => findMessageElement(messageId), 50);
|
||||
}
|
||||
return didScroll;
|
||||
},
|
||||
|
||||
holdViewportAnchor: (anchor) => {
|
||||
@@ -1730,7 +1787,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return () => {
|
||||
objectRef.current = null;
|
||||
};
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, turnIndexMap, ref]);
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]);
|
||||
|
||||
const anchoredEndSpace = React.useMemo<TimelineAnchoredEndSpace | undefined>(() => {
|
||||
const resolved = resolveChatListAnchoredEndSpace(
|
||||
|
||||
@@ -324,7 +324,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
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 effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
|
||||
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
|
||||
const currentVariant = currentVariantSelection.override ?? undefined;
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
|
||||
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
|
||||
@@ -332,6 +334,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const setModel = useConfigStore((state) => state.setModel);
|
||||
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
|
||||
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
|
||||
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||
const setAgent = useConfigStore((state) => state.setAgent);
|
||||
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||
@@ -693,6 +696,30 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return variants ? Object.keys(variants) : [];
|
||||
}, [providers]);
|
||||
|
||||
const resolveInheritedVariantForModel = React.useCallback((providerId: string, modelId: string, agentName?: string | null) => {
|
||||
const variantOptions = getModelVariantOptions(providerId, modelId);
|
||||
if (variantOptions.length === 0) return undefined;
|
||||
|
||||
let currentInherited: string | undefined;
|
||||
if (currentProviderId === providerId && currentModelId === modelId) {
|
||||
currentInherited = currentVariantSelection.inherited
|
||||
?? (currentVariantSelection.override === null || currentVariantSelection.override === undefined
|
||||
? effectiveCurrentVariant
|
||||
: undefined);
|
||||
}
|
||||
|
||||
const effectiveAgentName = agentName ?? uiAgentName ?? currentAgentName;
|
||||
const agent = effectiveAgentName ? agents.find((candidate) => candidate.name === effectiveAgentName) : undefined;
|
||||
const agentVariant = (
|
||||
agent?.model?.providerID === providerId
|
||||
&& agent.model.modelID === modelId
|
||||
) ? agent.variant : undefined;
|
||||
const candidates = currentSessionId
|
||||
? [agentVariant, settingsDefaultVariant, currentInherited]
|
||||
: [currentInherited, agentVariant, settingsDefaultVariant];
|
||||
return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate));
|
||||
}, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]);
|
||||
|
||||
const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => {
|
||||
const variantOptions = getModelVariantOptions(providerId, modelId);
|
||||
if (variantOptions.length === 0) {
|
||||
@@ -711,10 +738,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return currentVariant;
|
||||
}
|
||||
|
||||
if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) {
|
||||
return settingsDefaultVariant;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
currentAgentName,
|
||||
@@ -724,7 +747,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentVariant,
|
||||
getAgentModelVariantForSession,
|
||||
getModelVariantOptions,
|
||||
settingsDefaultVariant,
|
||||
uiAgentName,
|
||||
]);
|
||||
|
||||
@@ -748,7 +770,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
|
||||
manualVariantSelectionRef.current = true;
|
||||
setCurrentVariant(variant);
|
||||
setCurrentVariantOverride(
|
||||
variant ?? null,
|
||||
resolveInheritedVariantForModel(providerId, modelId, agentNameOverride),
|
||||
);
|
||||
addRecentEffort(providerId, modelId, variant);
|
||||
|
||||
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
|
||||
@@ -759,9 +784,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
addRecentEffort,
|
||||
currentSessionId,
|
||||
getModelVariantOptions,
|
||||
resolveInheritedVariantForModel,
|
||||
resolveLiveAgentName,
|
||||
saveAgentModelVariantForSession,
|
||||
setCurrentVariant,
|
||||
setCurrentVariantOverride,
|
||||
]);
|
||||
|
||||
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => {
|
||||
@@ -1121,18 +1148,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
|
||||
if (currentVariant && !availableVariants.includes(currentVariant)) {
|
||||
setCurrentVariant(undefined);
|
||||
setCurrentVariantOverride(
|
||||
null,
|
||||
resolveInheritedVariantForModel(currentProviderId, currentModelId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Draft state (no session yet): seed from settings default, but don't override
|
||||
// user selection while drafting.
|
||||
if (!currentSessionId) {
|
||||
if (!currentVariant && !manualVariantSelectionRef.current) {
|
||||
if (currentVariantSelection.override === undefined && !manualVariantSelectionRef.current) {
|
||||
const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
|
||||
? settingsDefaultVariant
|
||||
: undefined;
|
||||
setCurrentVariant(desired);
|
||||
setCurrentVariantOverride(desired ?? null, desired);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1144,13 +1174,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentModelId,
|
||||
);
|
||||
|
||||
const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
|
||||
? savedVariant
|
||||
: settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
|
||||
? settingsDefaultVariant
|
||||
: undefined;
|
||||
|
||||
setCurrentVariant(resolvedSaved);
|
||||
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
|
||||
if (savedVariant && availableVariants.includes(savedVariant)) {
|
||||
setCurrentVariantOverride(savedVariant, inheritedVariant);
|
||||
} else if (currentVariantSelection.override === null) {
|
||||
setCurrentVariantOverride(null, inheritedVariant);
|
||||
} else {
|
||||
setCurrentVariant(inheritedVariant);
|
||||
}
|
||||
manualVariantSelectionRef.current = false;
|
||||
}, [
|
||||
availableVariants,
|
||||
@@ -1160,8 +1191,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentVariant,
|
||||
currentVariantSelection.override,
|
||||
effectiveCurrentVariant,
|
||||
getAgentModelVariantForSession,
|
||||
resolveInheritedVariantForModel,
|
||||
setCurrentVariant,
|
||||
setCurrentVariantOverride,
|
||||
settingsDefaultVariant,
|
||||
]);
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
// Newest pending card owns the keyboard; older cards wait their turn.
|
||||
const activePermissionCardIds: string[] = [];
|
||||
|
||||
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
@@ -126,6 +130,33 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponseRef = React.useRef(handleResponse);
|
||||
handleResponseRef.current = handleResponse;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasResponded) return;
|
||||
activePermissionCardIds.push(permission.id);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (activePermissionCardIds.at(-1) !== permission.id) return;
|
||||
if (!event.altKey || event.metaKey || event.ctrlKey) return;
|
||||
const response = event.key === 'Enter'
|
||||
? (event.shiftKey ? 'always' as const : 'once' as const)
|
||||
: event.key === 'Backspace' && !event.shiftKey
|
||||
? 'reject' as const
|
||||
: null;
|
||||
if (!response) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleResponseRef.current(response);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
const index = activePermissionCardIds.lastIndexOf(permission.id);
|
||||
if (index !== -1) activePermissionCardIds.splice(index, 1);
|
||||
};
|
||||
}, [hasResponded, permission.id]);
|
||||
|
||||
if (hasResponded) {
|
||||
return null;
|
||||
}
|
||||
@@ -380,6 +411,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="check" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Allow Once
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+enter')}</kbd>
|
||||
</button>
|
||||
|
||||
{permission.always.length > 0 ? (
|
||||
@@ -436,6 +468,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="time" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Always Allow
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+shift+enter')}</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -459,6 +492,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="close" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Deny
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+backspace')}</kbd>
|
||||
</button>
|
||||
|
||||
{isResponding && (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React from "react";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// The floating assistant-status chip that hovers above the composer while the
|
||||
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
|
||||
// agent works ("Claude is working…"). ONLY that. The composer's
|
||||
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
|
||||
// to share this component, and every restyle of this chip (glass, placement)
|
||||
// silently dragged the composer bar and its dropdown along with it.
|
||||
@@ -17,10 +15,8 @@ interface StatusRowProps {
|
||||
statusText?: string | null;
|
||||
isGenericStatus?: boolean;
|
||||
isWaitingForPermission?: boolean;
|
||||
wasAborted?: boolean;
|
||||
abortActive?: boolean;
|
||||
retryInfo?: { attempt?: number; next?: number } | null;
|
||||
showAbortStatus?: boolean;
|
||||
agentName?: string;
|
||||
modelName?: string | null;
|
||||
providerId?: string | null;
|
||||
@@ -31,19 +27,16 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
statusText = null,
|
||||
isGenericStatus,
|
||||
isWaitingForPermission,
|
||||
wasAborted,
|
||||
abortActive,
|
||||
retryInfo,
|
||||
showAbortStatus,
|
||||
agentName,
|
||||
modelName,
|
||||
providerId,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
|
||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
|
||||
const shouldRenderPlaceholder = !abortActive;
|
||||
const hasContent = isWorking;
|
||||
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
@@ -63,14 +56,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
a shrink-to-fit wrapper around it always collapsed to zero. */}
|
||||
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
|
||||
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true"/>
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : shouldRenderPlaceholder ? (
|
||||
{shouldRenderPlaceholder ? (
|
||||
<WorkingPlaceholder
|
||||
key={currentSessionId ?? "no-session"}
|
||||
isWorking={isWorking}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { StatusRow } from './StatusRow';
|
||||
|
||||
@@ -12,15 +11,6 @@ import { StatusRow } from './StatusRow';
|
||||
* labels while still limiting subscriptions to the active assistant message.
|
||||
*/
|
||||
export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const abortRecord = useSessionUIStore(
|
||||
React.useCallback((state) => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
|
||||
}, [currentSessionId]),
|
||||
);
|
||||
const { activeModel, working } = useAssistantStatus();
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
@@ -35,16 +25,13 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
|
||||
}, [activeModel, providers]);
|
||||
|
||||
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
isWorking={working.isWorking}
|
||||
statusText={working.statusText}
|
||||
isGenericStatus={working.isGenericStatus}
|
||||
isWaitingForPermission={working.isWaitingForPermission}
|
||||
wasAborted={wasAborted || working.wasAborted}
|
||||
abortActive={wasAborted || working.abortActive}
|
||||
abortActive={working.abortActive}
|
||||
retryInfo={working.retryInfo}
|
||||
agentName={currentAgentName}
|
||||
modelName={modelDisplayName}
|
||||
|
||||
@@ -141,6 +141,9 @@ and the send path reading the same grammar.
|
||||
- `state/useDraftTarget.ts` — the draft can target a directory that does not
|
||||
exist yet (a worktree being created). It must survive not appearing in the
|
||||
branch list, or the selector snaps back to the project root mid-creation.
|
||||
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
|
||||
state and registers its application shortcuts locally. The selectors only
|
||||
consume their shared prefix while the draft target UI is mounted.
|
||||
|
||||
## Mobile
|
||||
|
||||
|
||||
@@ -17,6 +17,15 @@ import React from 'react';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
|
||||
|
||||
// Android mobile browsers are the pan-mode holdouts this pin exists for on
|
||||
// the CHAT screen too: interactive-widget=resizes-content is ignored by a
|
||||
// fair share of Android WebView/Chrome builds, and unlike iOS Safari they do
|
||||
// not reliably reveal the focused field either — the composer just stays
|
||||
// behind the keyboard. iOS keeps its browser-native reveal on the chat
|
||||
// screen, so this stays Android-only there.
|
||||
// Callers are browser-only React effects, so navigator always exists here.
|
||||
const isAndroidBrowser = (): boolean => /Android/i.test(navigator.userAgent);
|
||||
|
||||
export interface MobileViewportPinOptions {
|
||||
isMobile: boolean;
|
||||
/** Composer expanded to fullscreen on mobile. */
|
||||
@@ -96,12 +105,14 @@ export function useMobileViewportPin(options: MobileViewportPinOptions): void {
|
||||
};
|
||||
}, [editorRef, formRef, isFullscreen, isMobile]);
|
||||
|
||||
// Draft screen with the keyboard up: anchor the normal-height composer to
|
||||
// the visible bottom. The chat screen does not need this — its own
|
||||
// focused-field reveal works there.
|
||||
// Keyboard up: anchor the normal-height composer to the visible bottom.
|
||||
// Draft screen on every mobile browser; chat screen only on Android,
|
||||
// where neither viewport resizing nor the focused-field reveal can be
|
||||
// relied on (iOS chat keeps the browser's own reveal).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || isCapacitorApp()) return;
|
||||
if (!isDraftScreen || isFullscreen || !isFocused) return;
|
||||
if (isFullscreen || !isFocused) return;
|
||||
if (!isDraftScreen && !isAndroidBrowser()) return;
|
||||
const vv = window.visualViewport;
|
||||
const form = formRef.current;
|
||||
if (!vv || !form) return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,6 +27,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { normalizePath } from '../attachments/filePaths';
|
||||
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
|
||||
@@ -106,14 +108,48 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
onDirectoryChange,
|
||||
theme,
|
||||
} = props;
|
||||
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
|
||||
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (openPicker === null || !shouldDismissDropdown(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
useKeybind('open_draft_project_picker', () => {
|
||||
projectTriggerRef.current?.focus();
|
||||
setOpenPicker('project');
|
||||
});
|
||||
useKeybind('open_draft_worktree_picker', () => {
|
||||
if (!showBranchSelector) return false;
|
||||
worktreeTriggerRef.current?.focus();
|
||||
setOpenPicker('worktree');
|
||||
});
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
onProjectChange(projectId);
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
const handleDirectoryChange = (directory: string) => {
|
||||
onDirectoryChange(directory);
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
||||
<Select
|
||||
value={selectedProject.id}
|
||||
onValueChange={onProjectChange}
|
||||
open={openPicker === 'project'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
|
||||
onValueChange={handleProjectChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={projectTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
>
|
||||
@@ -123,9 +159,9 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
: <ProjectLabel project={selectedProject} theme={theme} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
<ProjectLabel project={project} theme={theme} />
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -135,9 +171,14 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{showBranchSelector ? (
|
||||
<Select
|
||||
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
|
||||
onValueChange={onDirectoryChange}
|
||||
open={openPicker === 'worktree'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
|
||||
onValueChange={handleDirectoryChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={worktreeTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
>
|
||||
@@ -145,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48">
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
|
||||
{projectRootBranchOption ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{projectRootBranchOption.label}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -168,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
</button>
|
||||
</div>
|
||||
{worktreeBranchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={option.value} value={option.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{option.pending ? '⏳ ' : ''}{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{selectedDirectory && !selectedBranchIsKnown ? (
|
||||
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate">
|
||||
<SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{selectedBranchLabel}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
|
||||
@@ -5,7 +5,12 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn, isMacOS } from '@/lib/utils';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getEffectiveShortcutCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
type FocusModeButtonProps = {
|
||||
footerIconButtonClass: string;
|
||||
@@ -17,6 +22,12 @@ type FocusModeButtonProps = {
|
||||
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
|
||||
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
|
||||
const { t } = useI18n();
|
||||
const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input);
|
||||
const expandInputCombo = getEffectiveShortcutCombo(
|
||||
'expand_input',
|
||||
expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride },
|
||||
);
|
||||
const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<div className="flex flex-col gap-0.5 text-center">
|
||||
<span>{t('chat.chatInput.focusMode.label')}</span>
|
||||
<span className="font-mono opacity-60">
|
||||
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
|
||||
</span>
|
||||
{shortcut ? <span className="font-mono opacity-60">{shortcut}</span> : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1343,16 +1343,6 @@ const AssistantMessageBody = React.memo(({
|
||||
return resolved ? { id: resolved.id, path: resolved.path } : null;
|
||||
}, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
|
||||
|
||||
const hasTools = toolParts.length > 0;
|
||||
|
||||
const hasPendingTools = React.useMemo(() => {
|
||||
return toolParts.some((toolPart) => {
|
||||
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
||||
const status = state?.status;
|
||||
return status === 'pending' || status === 'running' || status === 'started';
|
||||
});
|
||||
}, [toolParts]);
|
||||
|
||||
const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
|
||||
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
||||
const status = state?.status;
|
||||
@@ -1381,42 +1371,6 @@ const AssistantMessageBody = React.memo(({
|
||||
return isActiveTool(toolPart) || isToolFinalized(toolPart);
|
||||
}, [isActiveTool, isToolFinalized]);
|
||||
|
||||
const allToolsFinalized = React.useMemo(() => {
|
||||
if (toolParts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (hasPendingTools) {
|
||||
return false;
|
||||
}
|
||||
return toolParts.every((toolPart) => isToolFinalized(toolPart));
|
||||
}, [toolParts, hasPendingTools, isToolFinalized]);
|
||||
|
||||
const reasoningParts = React.useMemo(() => {
|
||||
return visibleParts.filter((part) => part.type === 'reasoning');
|
||||
}, [visibleParts]);
|
||||
|
||||
const reasoningComplete = React.useMemo(() => {
|
||||
if (reasoningParts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return reasoningParts.every((part) => {
|
||||
const time = (part as Record<string, unknown>).time as { end?: number } | undefined;
|
||||
return typeof time?.end === 'number';
|
||||
});
|
||||
}, [reasoningParts]);
|
||||
|
||||
// Message is considered to have an "open step" if info.finish is not yet present
|
||||
const hasOpenStep = typeof messageFinish !== 'string';
|
||||
|
||||
const shouldHoldForReasoning =
|
||||
reasoningParts.length > 0 &&
|
||||
hasTools &&
|
||||
(hasPendingTools || hasOpenStep || !allToolsFinalized);
|
||||
|
||||
const shouldHoldTools = awaitingMessageCompletion
|
||||
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
|
||||
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
|
||||
|
||||
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
|
||||
|
||||
const handleForkClick = React.useCallback(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
|
||||
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
@@ -106,7 +107,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const openRafRef = React.useRef<number | null>(null);
|
||||
const mouseUpTimeoutRef = React.useRef<number | null>(null);
|
||||
const isMenuVisibleRef = React.useRef(false);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
@@ -156,6 +157,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = null;
|
||||
if (openRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(openRafRef.current);
|
||||
openRafRef.current = null;
|
||||
@@ -169,6 +172,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const hideMenu = React.useCallback(() => {
|
||||
pendingSelectionRef.current = null;
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = null;
|
||||
setCommentRects(null);
|
||||
|
||||
if (!isMenuVisibleRef.current) {
|
||||
@@ -209,12 +214,30 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
return Math.min(Math.max(anchorX, minX), maxX);
|
||||
}, []);
|
||||
|
||||
const addMarkdownToChat = React.useCallback((markdownText: string) => {
|
||||
const markdownBlock = wrapMarkdownSelectionForChat(markdownText);
|
||||
setPendingInputText(markdownBlock, 'append');
|
||||
|
||||
hideMenu();
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [hideMenu, setPendingInputText]);
|
||||
|
||||
const showMenu = React.useCallback(() => {
|
||||
if (!pendingSelectionRef.current) return;
|
||||
|
||||
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
|
||||
const shouldAnimateIn = !position.show;
|
||||
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
|
||||
addToChat: () => addMarkdownToChat(markdownText),
|
||||
dismiss: hideMenu,
|
||||
});
|
||||
|
||||
// Position menu above the selection
|
||||
const menuX = isMobile
|
||||
? rect.left + rect.width / 2
|
||||
@@ -241,7 +264,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
openRafRef.current = null;
|
||||
});
|
||||
}
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
}, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!position.show || isMobile || !menuRef.current) {
|
||||
@@ -428,18 +451,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const handleAddToChat = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
|
||||
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
|
||||
setPendingInputText(markdownBlock, 'append');
|
||||
|
||||
hideMenu();
|
||||
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
||||
addMarkdownToChat(selectedTextMarkdown);
|
||||
}, [addMarkdownToChat, selectedTextMarkdown]);
|
||||
|
||||
const handleOpenComment = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
@@ -473,18 +486,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
});
|
||||
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
|
||||
|
||||
const handleCreateNewSession = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
const session = await createSession(undefined, null, null);
|
||||
if (session) {
|
||||
setPendingInputText(selectedText, 'replace');
|
||||
}
|
||||
|
||||
hideMenu();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, createSession, setPendingInputText, hideMenu]);
|
||||
|
||||
const currentSession = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
@@ -686,22 +687,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-new" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
@@ -763,39 +748,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
{t('chat.textSelection.actions.comment')}
|
||||
</button>
|
||||
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.addToInput')}
|
||||
</button>
|
||||
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.newSession')}
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user