Merge upstream main into feat/subagent-cost-rollup
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
|
||||
/**
|
||||
* Non-blocking notice that the OpenChamber session expired mid-work. It never
|
||||
* takes the screen on its own: work stays visible and interactive, and only
|
||||
* the explicit "Log in" click hands control to the session gate's full login
|
||||
* flow (password, passkey, desktop shell — all already there).
|
||||
*/
|
||||
export const AuthExpiredBanner: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const authState = useAuthSessionStore((store) => store.state);
|
||||
const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating);
|
||||
|
||||
if (authState !== 'expired') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// Below the header on purpose: the header row can be a window-drag region
|
||||
// on desktop, where nothing under the cursor is clickable.
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 z-[200] flex justify-center px-4"
|
||||
style={{ top: 'calc(var(--oc-header-height, 56px) + 8px)' }}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className="oc-glass-popover oc-glass-floating pointer-events-auto flex items-center gap-3 rounded-lg px-3 py-2"
|
||||
>
|
||||
<Icon name="lock" className="size-4 flex-shrink-0" style={{ color: 'var(--status-error)' }} />
|
||||
<span className="typography-ui-label text-foreground">{t('sessionAuth.expired.banner')}</span>
|
||||
<Button size="xs" variant="outline" onClick={markReauthenticating} className="normal-case">
|
||||
{t('sessionAuth.expired.loginAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { AuthExpiredBanner } from './AuthExpiredBanner';
|
||||
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
|
||||
@@ -351,6 +353,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const hasResyncedRef = React.useRef(skipAuth);
|
||||
const hasBootstrapResyncedRef = React.useRef(skipAuth);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -557,6 +560,27 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
// Mid-session expiry: the banner asks for a re-login by flipping the shared
|
||||
// auth store to 'reauthenticating'; the gate answers with its own status
|
||||
// check, which lands in the full 'locked' flow on a genuine 401. A
|
||||
// successful login resolves the store back to 'ok'.
|
||||
const authSessionState = useAuthSessionStore((store) => store.state);
|
||||
React.useEffect(() => {
|
||||
if (!skipAuth) installAuthSessionFocusWatch();
|
||||
}, [skipAuth]);
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) return;
|
||||
if (authSessionState === 'reauthenticating') {
|
||||
void checkStatusRef.current?.();
|
||||
}
|
||||
}, [authSessionState, skipAuth]);
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) return;
|
||||
if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') {
|
||||
useAuthSessionStore.getState().markAuthenticated();
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state === 'locked' && passwordInputRef.current) {
|
||||
passwordInputRef.current.focus();
|
||||
@@ -570,10 +594,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
if (state === 'authenticated' && !hasResyncedRef.current) {
|
||||
hasResyncedRef.current = true;
|
||||
// First authentication of this page load is bootstrap: adopt the
|
||||
// persisted workspace pointers. A re-login after mid-session expiry is
|
||||
// not — this window already has its own workspace, and the shared
|
||||
// settings document may carry another window's pointers.
|
||||
const isBootstrapResync = !hasBootstrapResyncedRef.current;
|
||||
hasBootstrapResyncedRef.current = true;
|
||||
void (async () => {
|
||||
await initializeAppearancePreferences();
|
||||
await syncDesktopSettings();
|
||||
await applyPersistedDirectoryPreferences();
|
||||
await syncDesktopSettings({ adoptWorkspace: isBootstrapResync });
|
||||
if (isBootstrapResync) {
|
||||
await applyPersistedDirectoryPreferences();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
@@ -983,5 +1015,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
{skipAuth ? null : <AuthExpiredBanner />}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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() ? (
|
||||
<>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
export interface InlineCommentInputProps {
|
||||
initialText?: string;
|
||||
@@ -37,6 +38,7 @@ export function InlineCommentInput({
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const [text, setText] = React.useState(initialText);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const saveShortcut = formatShortcutForDisplay('mod+enter');
|
||||
void isEditing;
|
||||
|
||||
const handleTextChange = (value: string) => {
|
||||
@@ -166,7 +168,9 @@ export function InlineCommentInput({
|
||||
value={text}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')}
|
||||
placeholder={isMobile
|
||||
? t('inlineComment.input.placeholderShort')
|
||||
: t('inlineComment.input.placeholder', { shortcut: saveShortcut })}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
|
||||
isMobile ? 'py-1.5 text-base leading-6' : 'py-1.5'
|
||||
|
||||
@@ -452,10 +452,13 @@ export const ContextPanel: React.FC = () => {
|
||||
|
||||
// Lets an agent's browser.open create the tab it needs when none is open yet.
|
||||
// Registered from the panel because opening a tab is panel state, not
|
||||
// something the browser view itself can do before it exists.
|
||||
// something the browser view itself can do before it exists. Background on
|
||||
// purpose: an agent working a page must not pop the panel open (or steal
|
||||
// the active surface) under the user — the tab mounts invisibly, and the
|
||||
// rail is where the user opens it when curious.
|
||||
React.useEffect(() => {
|
||||
if (!effectiveDirectory) return;
|
||||
return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url));
|
||||
return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url, { reveal: false }));
|
||||
}, [effectiveDirectory, openContextBrowser]);
|
||||
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
||||
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||
import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog';
|
||||
|
||||
const RAIL_TOOLTIP_DELAY_MS = 150;
|
||||
// Hold the surface-switch modifier for this long before revealing the order
|
||||
@@ -161,6 +162,7 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
|
||||
const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible);
|
||||
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
|
||||
const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces);
|
||||
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
@@ -256,12 +258,15 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const surfaces = React.useMemo(() => {
|
||||
return getVisibleContextRailSurfaces({
|
||||
railOrder: contextRailOrder,
|
||||
hiddenSurfaces: contextRailHiddenSurfaces,
|
||||
planModeEnabled,
|
||||
isVSCode: isVSCodeRuntime(),
|
||||
screenWidth,
|
||||
tabs,
|
||||
});
|
||||
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||
}, [contextRailHiddenSurfaces, contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||
|
||||
const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false);
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -331,6 +336,24 @@ export const ContextPanelRail: React.FC = () => {
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{/* Outside the sortable list on purpose: this button takes no digit,
|
||||
cannot be dragged, and configures the rail rather than living on it. */}
|
||||
<Tooltip delayDuration={RAIL_TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('contextRail.configure.open')}
|
||||
onClick={() => setIsSurfacesDialogOpen(true)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="equalizer-2" className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
{t('contextRail.configure.open')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ContextRailSurfacesDialog open={isSurfacesDialogOpen} onOpenChange={setIsSurfacesDialogOpen} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { sortContextSurfaces } from '@/lib/surfaces/registry';
|
||||
|
||||
/**
|
||||
* Which surfaces the context rail shows. Everything is on by default and the
|
||||
* choice is stored as the *hidden* set, so a surface added in a later release
|
||||
* appears for everyone rather than staying invisible to whoever had saved
|
||||
* settings before it existed. Hidden surfaces also leave the digit shortcuts
|
||||
* (the rail and the shortcut share one visibility filter).
|
||||
*/
|
||||
export const ContextRailSurfacesDialog: React.FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
|
||||
const hidden = useUIStore((state) => state.contextRailHiddenSurfaces);
|
||||
const setSurfaceVisible = useUIStore((state) => state.setContextRailSurfaceVisible);
|
||||
const setHiddenSurfaces = useUIStore((state) => state.setContextRailHiddenSurfaces);
|
||||
|
||||
// The full registry in the user's rail order — including surfaces a runtime
|
||||
// filter currently drops, so a choice made on desktop is editable anywhere.
|
||||
const surfaces = React.useMemo(() => sortContextSurfaces(contextRailOrder), [contextRailOrder]);
|
||||
|
||||
const allVisible = hidden.length === 0;
|
||||
const noneVisible = surfaces.every((surface) => hidden.includes(surface.id));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('contextRail.configure.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('contextRail.configure.dialogDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{surfaces.map((surface) => (
|
||||
<SettingsCheckboxRow
|
||||
key={surface.id}
|
||||
settingsItem={`layout.context-rail.surface.${surface.id}`}
|
||||
checked={!hidden.includes(surface.id)}
|
||||
onChange={(checked) => setSurfaceVisible(surface.id, checked)}
|
||||
label={t(surface.labelKey)}
|
||||
ariaLabel={t(surface.labelKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!allVisible ? (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
{noneVisible ? (
|
||||
<span className="text-xs text-destructive">{t('contextRail.configure.noneWarning')}</span>
|
||||
) : <span />}
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
onClick={() => setHiddenSurfaces([])}
|
||||
className="normal-case text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t('contextRail.configure.showAll')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -38,7 +38,8 @@ import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControl
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo, type ShortcutActionId } from '@/lib/shortcuts';
|
||||
import { useKeybinds } from '@/hooks/useKeybind';
|
||||
import {
|
||||
} from '@/lib/quota/model-families';
|
||||
|
||||
@@ -256,7 +257,7 @@ type DesktopServicesMenuProps = {
|
||||
isDesktopServicesOpen: boolean;
|
||||
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refreshCurrentInstanceLabel: () => Promise<void>;
|
||||
shortcutLabel: (actionId: string) => string;
|
||||
shortcutLabel: (actionId: ShortcutActionId) => string;
|
||||
remoteUpdateInfo: UpdateInfo | null;
|
||||
remoteUpdateChecking: boolean;
|
||||
remoteUpdateError: string | null;
|
||||
@@ -433,7 +434,6 @@ export const Header: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
const openContextOverview = useUIStore((state) => state.openContextOverview);
|
||||
const openContextPlan = useUIStore((state) => state.openContextPlan);
|
||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled);
|
||||
@@ -485,8 +485,6 @@ export const Header: React.FC = () => {
|
||||
const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean);
|
||||
return pathSegments[pathSegments.length - 1] ?? null;
|
||||
}, [activeProject]);
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
@@ -1264,21 +1262,6 @@ export const Header: React.FC = () => {
|
||||
const isContextPanelActive = activeContextMode === 'context';
|
||||
|
||||
|
||||
const handleOpenContextPlan = React.useCallback(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'plan') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextPlan(directory);
|
||||
}, [closeContextPanel, openContextPlan, openDirectory]);
|
||||
|
||||
|
||||
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
|
||||
// Left padding the header needs to clear the OS window controls (macOS
|
||||
@@ -1445,67 +1428,26 @@ export const Header: React.FC = () => {
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const shortcutLabel = React.useCallback((actionId: string) => {
|
||||
const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => {
|
||||
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
|
||||
}, [shortcutOverrides]);
|
||||
|
||||
// Desktop keeps instances only: quota and MCP now live in the work-status
|
||||
// panel, which reports them per session rather than per window. The mobile
|
||||
// menu below is untouched — it has no panel to defer to.
|
||||
const servicesTabs = React.useMemo(() => {
|
||||
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = [];
|
||||
if (isDesktopApp) {
|
||||
base.push({ value: 'instance', label: t('layout.services.instance'), icon: <Icon name="server" className="h-3.5 w-3.5" /> });
|
||||
}
|
||||
return base;
|
||||
}, [isDesktopApp, t]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, toggleServicesCombo)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isDesktopServicesOpen) {
|
||||
setIsDesktopServicesOpen(false);
|
||||
} else {
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
}
|
||||
useKeybinds({
|
||||
rename_current_session: () => {
|
||||
if (!currentSessionId || isMobile) return false;
|
||||
beginHeaderSessionRename();
|
||||
},
|
||||
toggle_services_menu: () => {
|
||||
if (isDesktopServicesOpen) {
|
||||
setIsDesktopServicesOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The desktop menu holds one destination now, so this shortcut opens it
|
||||
// rather than cycling. The binding is kept: it is user-configurable and
|
||||
// silently dropping it would break existing setups.
|
||||
const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, cycleServicesCombo)) {
|
||||
e.preventDefault();
|
||||
if (servicesTabs.length === 0) return;
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, toggleContextPlanCombo)) {
|
||||
e.preventDefault();
|
||||
handleOpenContextPlan();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [
|
||||
shortcutOverrides,
|
||||
isDesktopServicesOpen,
|
||||
servicesTabs,
|
||||
quotaResults.length,
|
||||
fetchAllQuotas,
|
||||
refreshCurrentInstanceLabel,
|
||||
handleOpenContextPlan,
|
||||
]);
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
},
|
||||
});
|
||||
|
||||
const desktopSidebarActions = (
|
||||
<>
|
||||
|
||||
@@ -42,6 +42,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const setModel = useConfigStore((state) => state.setModel);
|
||||
const setAgent = useConfigStore((state) => state.setAgent);
|
||||
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
|
||||
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
|
||||
const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel);
|
||||
const setSettingsDefaultVariant = useConfigStore((state) => state.setSettingsDefaultVariant);
|
||||
const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent);
|
||||
@@ -210,7 +211,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
setDefaultVariant(newValue);
|
||||
setSettingsDefaultVariant(newValue);
|
||||
if (!chatHasOwnModel) {
|
||||
setCurrentVariant(newValue);
|
||||
setCurrentVariantOverride(newValue ?? null, newValue);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -219,7 +220,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
console.warn('Failed to save default variant:', error);
|
||||
}
|
||||
},
|
||||
[chatHasOwnModel, setCurrentVariant, setSettingsDefaultVariant]
|
||||
[chatHasOwnModel, setCurrentVariantOverride, setSettingsDefaultVariant]
|
||||
);
|
||||
|
||||
const handleAgentChange = React.useCallback(
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsFieldRow, SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import {
|
||||
@@ -14,314 +9,128 @@ import {
|
||||
getCustomizableShortcutActions,
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
isRiskyBrowserShortcut,
|
||||
keyToShortcutToken,
|
||||
normalizeCombo,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
type ShortcutActionId,
|
||||
type ShortcutCategory,
|
||||
type ShortcutCombo,
|
||||
type CustomizableShortcutAction,
|
||||
} from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ShortcutRecordingDialog } from './ShortcutRecordingDialog';
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||
|
||||
const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
parts.push('mod');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
parts.push('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
parts.push('alt');
|
||||
}
|
||||
|
||||
const keyToken = keyToShortcutToken(event.key);
|
||||
if (!keyToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parts.push(keyToken);
|
||||
return normalizeCombo(parts.join('+'));
|
||||
};
|
||||
|
||||
// Prefix capture for chord-style shortcuts (e.g. "switch context panel
|
||||
// surface"): a bare modifier press is accepted so the prefix can be just the
|
||||
// primary modifier (default) or a modifier + key chord like `mod+p`.
|
||||
const keyboardEventToPrefixCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
parts.push('mod');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
parts.push('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
parts.push('alt');
|
||||
}
|
||||
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
}
|
||||
|
||||
const keyToken = keyToShortcutToken(event.key);
|
||||
if (!keyToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parts.push(keyToken);
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
};
|
||||
const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigation', 'application'];
|
||||
|
||||
export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const setShortcutOverride = useUIStore((state) => state.setShortcutOverride);
|
||||
const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride);
|
||||
const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides);
|
||||
const [editingAction, setEditingAction] = React.useState<CustomizableShortcutAction | null>(null);
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
const all = getCustomizableShortcutActions();
|
||||
if (!isVSCodeRuntime()) {
|
||||
return all;
|
||||
}
|
||||
return all.filter((action) => action.id !== 'toggle_prompt_navigator');
|
||||
return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all;
|
||||
}, []);
|
||||
const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => {
|
||||
const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`;
|
||||
const translated = tUnsafe(key);
|
||||
return translated === key ? fallbackLabel : translated;
|
||||
}, [tUnsafe]);
|
||||
|
||||
const [capturingActionId, setCapturingActionId] = React.useState<string | null>(null);
|
||||
const [draftByAction, setDraftByAction] = React.useState<Record<string, ShortcutCombo>>({});
|
||||
const [errorText, setErrorText] = React.useState<string>('');
|
||||
const [warningText, setWarningText] = React.useState<string>('');
|
||||
const [pendingOverwrite, setPendingOverwrite] = React.useState<{
|
||||
actionId: string;
|
||||
combo: ShortcutCombo;
|
||||
conflictActionId: string;
|
||||
} | null>(null);
|
||||
|
||||
const persistShortcutOverrides = React.useCallback((nextOverrides: Record<string, ShortcutCombo>) => {
|
||||
const persist = (nextOverrides: Record<string, ShortcutCombo>) => {
|
||||
void updateDesktopSettings({ shortcutOverrides: nextOverrides });
|
||||
}, []);
|
||||
|
||||
const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => {
|
||||
const normalized = normalizeCombo(combo);
|
||||
for (const action of actions) {
|
||||
if (action.id === actionId) {
|
||||
continue;
|
||||
}
|
||||
const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
if (normalizeCombo(existing) === normalized) {
|
||||
return action.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [actions, shortcutOverrides]);
|
||||
|
||||
const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => {
|
||||
const normalized = normalizeCombo(combo);
|
||||
const conflictActionId = findConflict(actionId, normalized);
|
||||
if (conflictActionId) {
|
||||
setPendingOverwrite({ actionId, combo: normalized, conflictActionId });
|
||||
setErrorText('');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOverrides = { ...shortcutOverrides, [actionId]: normalized };
|
||||
setShortcutOverride(actionId, normalized);
|
||||
persistShortcutOverrides(nextOverrides);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [findConflict, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]);
|
||||
|
||||
const confirmOverwrite = React.useCallback(() => {
|
||||
if (!pendingOverwrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOverrides = {
|
||||
...shortcutOverrides,
|
||||
[pendingOverwrite.conflictActionId]: UNASSIGNED_SHORTCUT,
|
||||
[pendingOverwrite.actionId]: pendingOverwrite.combo,
|
||||
};
|
||||
setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT);
|
||||
setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo);
|
||||
persistShortcutOverrides(nextOverrides);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[pendingOverwrite.actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [pendingOverwrite, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]);
|
||||
|
||||
const resetOne = React.useCallback((actionId: string) => {
|
||||
};
|
||||
const save = (
|
||||
actionId: ShortcutActionId,
|
||||
combo: ShortcutCombo,
|
||||
replaceActionId?: ShortcutActionId,
|
||||
) => {
|
||||
const nextOverrides = { ...shortcutOverrides, [actionId]: combo };
|
||||
if (replaceActionId) nextOverrides[replaceActionId] = UNASSIGNED_SHORTCUT;
|
||||
setShortcutOverride(actionId, combo);
|
||||
if (replaceActionId) setShortcutOverride(replaceActionId, UNASSIGNED_SHORTCUT);
|
||||
persist(nextOverrides);
|
||||
};
|
||||
const resetOne = (actionId: ShortcutActionId) => {
|
||||
const nextOverrides = { ...shortcutOverrides };
|
||||
delete nextOverrides[actionId];
|
||||
clearShortcutOverride(actionId);
|
||||
persistShortcutOverrides(nextOverrides);
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[actionId];
|
||||
return rest;
|
||||
});
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText('');
|
||||
}, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]);
|
||||
persist(nextOverrides);
|
||||
};
|
||||
const shortcutDisplay = (action: CustomizableShortcutAction): string => {
|
||||
const isPrefixStyle = 'prefixStyle' in action && action.prefixStyle;
|
||||
const combo = isPrefixStyle
|
||||
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
|
||||
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const formatted = formatShortcutForDisplay(
|
||||
combo,
|
||||
t('settings.openchamber.keyboardShortcuts.unassigned'),
|
||||
);
|
||||
if (!isPrefixStyle || !combo || combo === UNASSIGNED_SHORTCUT) return formatted;
|
||||
const suffix = action.id === 'switch_session_tab'
|
||||
? t('settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix')
|
||||
: t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix');
|
||||
return `${formatted}${suffix}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
settingsItem="shortcuts.keyboard-shortcuts"
|
||||
title={t('settings.openchamber.keyboardShortcuts.title')}
|
||||
divider={false}
|
||||
info={t('settings.openchamber.keyboardShortcuts.tooltip')}
|
||||
headerAction={(
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
resetAllShortcutOverrides();
|
||||
persistShortcutOverrides({});
|
||||
setDraftByAction({});
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText('');
|
||||
}}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{(errorText || warningText || pendingOverwrite) && (
|
||||
<div className="mb-2 space-y-2">
|
||||
{pendingOverwrite && (
|
||||
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col @xl:flex-row @xl:items-center justify-between gap-3">
|
||||
<span className="typography-meta text-foreground">
|
||||
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
|
||||
</span>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>{t('settings.openchamber.keyboardShortcuts.actions.overwrite')}</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>{t('settings.common.actions.cancel')}</Button>
|
||||
</div>
|
||||
<>
|
||||
{CATEGORIES.map((category, categoryIndex) => {
|
||||
const categoryActions = actions.filter((action) => action.category === category);
|
||||
if (categoryActions.length === 0) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
key={category}
|
||||
settingsItem={categoryIndex === 0 ? 'shortcuts.keyboard-shortcuts' : undefined}
|
||||
title={t(`settings.openchamber.keyboardShortcuts.category.${category}`)}
|
||||
divider={categoryIndex !== 0}
|
||||
info={categoryIndex === 0 ? t('settings.openchamber.keyboardShortcuts.tooltip') : undefined}
|
||||
headerAction={categoryIndex === 0 ? (
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => {
|
||||
resetAllShortcutOverrides();
|
||||
persist({});
|
||||
}}>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
|
||||
</Button>
|
||||
) : undefined}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{categoryActions.map((action) => (
|
||||
<SettingsFieldRow key={action.id} label={t(action.settingsLabelKey)}>
|
||||
<kbd
|
||||
className="min-w-32 rounded-md border border-border bg-muted px-2 py-1 text-center typography-meta font-mono text-foreground"
|
||||
>
|
||||
{shortcutDisplay(action)}
|
||||
</kbd>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setEditingAction(action)}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.edit')}
|
||||
</Button>
|
||||
{action.id in shortcutOverrides ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => resetOne(action.id)}
|
||||
>
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
) : null}
|
||||
</SettingsFieldRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errorText && (
|
||||
<div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-3 typography-meta text-foreground">
|
||||
{errorText}
|
||||
</div>
|
||||
)}
|
||||
{warningText && (
|
||||
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 typography-meta text-foreground">
|
||||
{warningText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{actions.map((action, index) => {
|
||||
const isSurfaceSwitch = action.id === 'switch_context_surface';
|
||||
const effective = isSurfaceSwitch
|
||||
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
|
||||
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const draft = draftByAction[action.id];
|
||||
const displayCombo = draft ?? effective;
|
||||
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
|
||||
const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT;
|
||||
const displayValue = capturingActionId === action.id
|
||||
? t('settings.openchamber.keyboardShortcuts.field.pressKeys')
|
||||
: isSurfaceSwitch && !isUnassignedDisplay
|
||||
? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
|
||||
: formatShortcutForDisplay(displayCombo);
|
||||
|
||||
return (
|
||||
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
|
||||
<SettingsFieldRow
|
||||
label={actionLabel(action.id, action.label)}
|
||||
alignEnd={false}
|
||||
>
|
||||
<Input
|
||||
readOnly
|
||||
value={displayValue}
|
||||
onFocus={() => {
|
||||
setCapturingActionId(action.id);
|
||||
setErrorText('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (capturingActionId === action.id) {
|
||||
setCapturingActionId(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setCapturingActionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event);
|
||||
if (!combo) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftByAction((current) => ({
|
||||
...current,
|
||||
[action.id]: combo,
|
||||
}));
|
||||
setCapturingActionId(null);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
}}
|
||||
className="h-7 w-40 min-w-0 typography-ui-label text-center"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
const next = draftByAction[action.id];
|
||||
if (!next) {
|
||||
setErrorText(t('settings.openchamber.keyboardShortcuts.error.captureFirst'));
|
||||
return;
|
||||
}
|
||||
saveCombo(action.id, next);
|
||||
}}
|
||||
disabled={!hasDraft}
|
||||
>
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</SettingsFieldRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsSection>
|
||||
);
|
||||
})}
|
||||
<ShortcutRecordingDialog
|
||||
action={editingAction}
|
||||
overrides={shortcutOverrides}
|
||||
onSave={save}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingAction(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { TerminalShellOption } from '@/lib/api/types';
|
||||
import { isTerminalShell } from '@/lib/terminalShell';
|
||||
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
interface Option<T extends string> {
|
||||
id: T;
|
||||
@@ -1480,7 +1481,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
label={t('settings.openchamber.visual.field.terminalQuickKeys')}
|
||||
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
|
||||
settingsItem="appearance.terminal-quick-keys"
|
||||
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')}
|
||||
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip', {
|
||||
control: formatShortcutForDisplay('ctrl'),
|
||||
alt: formatShortcutForDisplay('alt'),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { settleShortcutRecordingState, updateShortcutRecordingState } from './ShortcutRecordingDialog';
|
||||
|
||||
const emptyState = { chords: [], livePreview: null, settled: false };
|
||||
|
||||
function keyEvent(key: string, modifiers: Partial<Record<'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey', boolean>> = {}) {
|
||||
const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : key;
|
||||
return { key, code, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers };
|
||||
}
|
||||
|
||||
describe('ShortcutRecordingDialog recording state', () => {
|
||||
test('previews modifiers and clears the preview when they are released', () => {
|
||||
const pressed = updateShortcutRecordingState(emptyState, keyEvent('Control', { ctrlKey: true, shiftKey: true }), 'keydown');
|
||||
expect(pressed.livePreview).toBe('mod+shift');
|
||||
expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull();
|
||||
});
|
||||
|
||||
test('waits after the first chord and settles when a second chord is recorded', () => {
|
||||
const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
|
||||
const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown');
|
||||
const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown');
|
||||
expect(first.chords).toEqual(['mod+s']);
|
||||
expect(first.settled).toBe(false);
|
||||
expect(second.chords).toEqual(['mod+s', 'p']);
|
||||
expect(second.settled).toBe(true);
|
||||
expect(third.chords).toEqual(['x']);
|
||||
expect(third.settled).toBe(false);
|
||||
});
|
||||
|
||||
test('settles a single chord for timeout and Confirm validation', () => {
|
||||
const waiting = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
|
||||
expect(settleShortcutRecordingState(waiting)).toEqual({ chords: ['mod+s'], livePreview: null, settled: true });
|
||||
});
|
||||
|
||||
test('records at most three simultaneous keys', () => {
|
||||
const previous = { chords: ['mod+k'], livePreview: null, settled: false };
|
||||
const threeKeys = updateShortcutRecordingState(
|
||||
previous,
|
||||
keyEvent('s', { ctrlKey: true, shiftKey: true }),
|
||||
'keydown',
|
||||
);
|
||||
const fourKeys = updateShortcutRecordingState(
|
||||
previous,
|
||||
keyEvent('s', { ctrlKey: true, metaKey: true, shiftKey: true }),
|
||||
'keydown',
|
||||
);
|
||||
|
||||
expect(threeKeys.chords).toEqual(['mod+k', 'mod+shift+s']);
|
||||
expect(fourKeys.chords).toEqual(['mod+k']);
|
||||
});
|
||||
|
||||
test('ignores repeat and IME events', () => {
|
||||
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown')).toEqual(emptyState);
|
||||
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown')).toEqual(emptyState);
|
||||
});
|
||||
|
||||
test('records Enter and Escape while Backspace removes the final chord', () => {
|
||||
const state = { chords: ['mod+k', 'mod+p'], livePreview: null, settled: true };
|
||||
expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']);
|
||||
expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']);
|
||||
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').chords).toEqual(['mod+k']);
|
||||
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').settled).toBe(false);
|
||||
expect(updateShortcutRecordingState({ chords: ['mod+k'], livePreview: null, settled: false }, keyEvent('Backspace'), 'keydown')).toEqual(emptyState);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getShortcutBindingConflicts,
|
||||
isRiskyBrowserShortcut,
|
||||
keyToShortcutToken,
|
||||
resolveShortcutEventKey,
|
||||
normalizeCombo,
|
||||
type ShortcutActionId,
|
||||
type ShortcutBindingConflict,
|
||||
type ShortcutCombo,
|
||||
type CustomizableShortcutAction,
|
||||
} from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||
const MAX_SHORTCUT_KEY_COUNT = 3;
|
||||
const SECOND_CHORD_TIMEOUT_MS = 3000;
|
||||
|
||||
interface RecordingKeyboardEvent {
|
||||
altKey: boolean;
|
||||
code: string;
|
||||
ctrlKey: boolean;
|
||||
isComposing: boolean;
|
||||
key: string;
|
||||
metaKey: boolean;
|
||||
repeat: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
interface ShortcutRecordingState {
|
||||
chords: ShortcutCombo[];
|
||||
livePreview: ShortcutCombo | null;
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
interface ShortcutRecordingDialogProps {
|
||||
action: CustomizableShortcutAction | null;
|
||||
overrides: Record<string, string>;
|
||||
onSave: (
|
||||
actionId: ShortcutActionId,
|
||||
combo: ShortcutCombo,
|
||||
replaceActionId?: ShortcutActionId,
|
||||
) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function getPhysicalKeyCount(
|
||||
event: Pick<RecordingKeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
|
||||
includeEventKey = false,
|
||||
): number {
|
||||
const keys = new Set<string>();
|
||||
if (event.altKey) keys.add('alt');
|
||||
if (event.ctrlKey) keys.add('control');
|
||||
if (event.metaKey) keys.add('meta');
|
||||
if (event.shiftKey) keys.add('shift');
|
||||
if (includeEventKey) keys.add(event.key.toLowerCase());
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
function isCustomizableConflict(
|
||||
conflict: ShortcutBindingConflict,
|
||||
): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } {
|
||||
return conflict.action.customizable;
|
||||
}
|
||||
|
||||
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||
if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||
const parts: string[] = [];
|
||||
if (event.metaKey || event.ctrlKey) parts.push('mod');
|
||||
if (event.shiftKey) parts.push('shift');
|
||||
if (event.altKey) parts.push('alt');
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
}
|
||||
|
||||
function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null;
|
||||
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||
|
||||
const key = keyToShortcutToken(resolveShortcutEventKey(event));
|
||||
if (!key) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (event.metaKey || event.ctrlKey) parts.push('mod');
|
||||
if (event.shiftKey) parts.push('shift');
|
||||
if (event.altKey) parts.push('alt');
|
||||
parts.push(key);
|
||||
return normalizeCombo(parts.join('+'));
|
||||
}
|
||||
|
||||
function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): ShortcutCombo | null {
|
||||
const key = event.key.toLowerCase();
|
||||
if (!MODIFIER_KEYS.has(key)) return null;
|
||||
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod');
|
||||
if (event.shiftKey || key === 'shift') parts.push('shift');
|
||||
if (event.altKey || key === 'alt') parts.push('alt');
|
||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
|
||||
export function settleShortcutRecordingState(state: ShortcutRecordingState): ShortcutRecordingState {
|
||||
return state.chords.length > 0 ? { ...state, livePreview: null, settled: true } : state;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
|
||||
export function updateShortcutRecordingState(
|
||||
state: ShortcutRecordingState,
|
||||
event: RecordingKeyboardEvent,
|
||||
phase: 'keydown' | 'keyup',
|
||||
): ShortcutRecordingState {
|
||||
if (event.repeat || event.isComposing) return state;
|
||||
if (phase === 'keyup') {
|
||||
return { ...state, livePreview: getModifierPreview(event) };
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
return { chords: state.chords.slice(0, -1), livePreview: null, settled: false };
|
||||
}
|
||||
|
||||
const chord = keyboardEventToCombo(event);
|
||||
if (chord) {
|
||||
if (state.settled) {
|
||||
return { chords: [chord], livePreview: null, settled: false };
|
||||
}
|
||||
const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords;
|
||||
return {
|
||||
chords,
|
||||
livePreview: null,
|
||||
settled: chords.length === 2,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...state, livePreview: getModifierPreview(event) };
|
||||
}
|
||||
|
||||
export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = ({
|
||||
action,
|
||||
overrides,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
|
||||
const conflictActionLabel = (conflict: ShortcutBindingConflict) => (
|
||||
conflict.action.customizable
|
||||
? actionLabel(conflict.action)
|
||||
: formatShortcutForDisplay(conflict.action.defaultBinding)
|
||||
);
|
||||
const [recording, setRecording] = React.useState<ShortcutRecordingState>({ chords: [], livePreview: null, settled: false });
|
||||
const recordingRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!action) return;
|
||||
setRecording({ chords: [], livePreview: null, settled: false });
|
||||
recordingRef.current?.focus();
|
||||
}, [action]);
|
||||
|
||||
const waitingForSecondChord = recording.chords.length === 1 && !recording.settled;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!waitingForSecondChord) return;
|
||||
const timeout = window.setTimeout(
|
||||
() => setRecording(settleShortcutRecordingState),
|
||||
SECOND_CHORD_TIMEOUT_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [waitingForSecondChord]);
|
||||
|
||||
const combo = normalizeCombo(recording.chords.join(' '));
|
||||
const conflicts = React.useMemo(
|
||||
() => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [],
|
||||
[action, combo, overrides],
|
||||
);
|
||||
const protectedConflict = conflicts.find((conflict) => (
|
||||
!conflict.action.customizable && conflict.kind !== 'contextual-prefix'
|
||||
));
|
||||
const customizableConflicts = conflicts.filter(isCustomizableConflict);
|
||||
const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix');
|
||||
const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact');
|
||||
const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix');
|
||||
|
||||
const close = () => onOpenChange(false);
|
||||
const confirm = () => {
|
||||
if (!recording.settled) setRecording(settleShortcutRecordingState);
|
||||
if (!action || !combo || protectedConflict || prefixConflict) return;
|
||||
onSave(action.id, combo, exactConflict?.action.id);
|
||||
close();
|
||||
};
|
||||
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const isPrefixStyleAction = Boolean(action && 'prefixStyle' in action && action.prefixStyle);
|
||||
if (phase === 'keyup' && isPrefixStyleAction && recording.chords.length === 0) {
|
||||
const modifierCombo = modifierKeyUpToCombo(event);
|
||||
if (modifierCombo) {
|
||||
setRecording({ chords: [modifierCombo], livePreview: null, settled: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const nextRecording = updateShortcutRecordingState(recording, {
|
||||
altKey: event.altKey,
|
||||
code: event.nativeEvent.code,
|
||||
ctrlKey: event.ctrlKey,
|
||||
isComposing: event.nativeEvent.isComposing,
|
||||
key: event.key,
|
||||
metaKey: event.metaKey,
|
||||
repeat: event.repeat,
|
||||
shiftKey: event.shiftKey,
|
||||
}, phase);
|
||||
setRecording(isPrefixStyleAction && nextRecording.chords.length > 1
|
||||
? recording
|
||||
: nextRecording);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={action !== null}
|
||||
onOpenChange={(open, eventDetails) => {
|
||||
if (!open) {
|
||||
eventDetails.cancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md" initialFocus={recordingRef} showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('settings.openchamber.keyboardShortcuts.dialog.instructions')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="flex min-h-28 items-center justify-center rounded-lg border border-border bg-[var(--surface-elevated)] px-4 py-5 text-center outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
tabIndex={0}
|
||||
ref={recordingRef}
|
||||
onKeyDown={(event) => handleRecordingEvent(event, 'keydown')}
|
||||
onKeyUp={(event) => handleRecordingEvent(event, 'keyup')}
|
||||
onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{recording.chords.map((chord, index) => (
|
||||
<kbd key={`${chord}-${index}`} className="rounded-md border border-border bg-muted px-3 py-2 typography-ui-label font-mono text-foreground">
|
||||
{formatShortcutForDisplay(chord)}
|
||||
</kbd>
|
||||
))}
|
||||
{recording.livePreview ? (
|
||||
<kbd className="rounded-md border border-dashed border-border bg-muted px-3 py-2 typography-ui-label font-mono text-muted-foreground">
|
||||
{formatShortcutForDisplay(recording.livePreview)}
|
||||
</kbd>
|
||||
) : null}
|
||||
{recording.chords.length === 0 && !recording.livePreview ? (
|
||||
<span className="typography-ui-label text-muted-foreground">
|
||||
{t('settings.openchamber.keyboardShortcuts.dialog.recording')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recording.settled && protectedConflict ? (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.internalConflict')}
|
||||
</p>
|
||||
) : recording.settled && prefixConflict ? (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
|
||||
</p>
|
||||
) : null}
|
||||
{recording.settled && exactConflict && !protectedConflict && !prefixConflict ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
|
||||
</p>
|
||||
) : null}
|
||||
{recording.settled && contextualPrefixConflict && !protectedConflict && !prefixConflict ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.warning.contextualPrefix', {
|
||||
action: conflictActionLabel(contextualPrefixConflict),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{recording.settled && combo && isRiskyBrowserShortcut(combo) ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={close}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!combo || (recording.settled && (Boolean(protectedConflict) || Boolean(prefixConflict)))}
|
||||
onClick={confirm}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import {
|
||||
isFilesystemError,
|
||||
type FilesystemErrorReason,
|
||||
@@ -360,9 +361,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const hasHighlightedBrowseItem = Boolean(
|
||||
highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled))
|
||||
);
|
||||
const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
? '⌘'
|
||||
: 'Ctrl';
|
||||
const submitModifierLabel = formatShortcutForDisplay('mod');
|
||||
const submitActionLabel = isAlreadyAdded
|
||||
? t('directoryExplorerDialog.actions.alreadyAdded')
|
||||
: isCloneMode
|
||||
|
||||
@@ -11,7 +11,11 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||
import {
|
||||
findSwitcherItemAncestorIds,
|
||||
useSwitcherItems,
|
||||
type SwitcherItem,
|
||||
} from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
||||
@@ -22,6 +26,7 @@ import { cn } from '@/lib/utils';
|
||||
type SecondaryMeta = SwitcherItem['secondaryMeta'];
|
||||
|
||||
type SwitcherVariant = 'default' | 'compact';
|
||||
const NEW_SESSION_SWITCHER_TARGET = 'new-session';
|
||||
|
||||
type SessionSwitcherDropdownProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -40,7 +45,7 @@ export function SessionSwitcherDropdown({
|
||||
const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false} disableGlobalShortcuts>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
@@ -69,7 +74,9 @@ type SwitcherContentProps = {
|
||||
};
|
||||
|
||||
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
|
||||
const items = useSwitcherItems(true, { scopeProjectId });
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true);
|
||||
const items = useSwitcherItems(true, { scopeProjectId, currentSessionId });
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -79,6 +86,9 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
||||
}, [onSelect, openNewSessionDraft]);
|
||||
|
||||
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const initialFocusCompleteRef = React.useRef(false);
|
||||
const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId;
|
||||
const toggleParent = React.useCallback((sessionId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -91,10 +101,36 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (initialFocusCompleteRef.current || !initialTarget) return;
|
||||
|
||||
const ancestorIds = initialTarget === NEW_SESSION_SWITCHER_TARGET
|
||||
? []
|
||||
: findSwitcherItemAncestorIds(items, initialTarget);
|
||||
if (!ancestorIds) return;
|
||||
|
||||
if (ancestorIds.some((id) => !expandedParents.has(id))) {
|
||||
setExpandedParents((previous) => new Set([...previous, ...ancestorIds]));
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrame = requestAnimationFrame(() => {
|
||||
const item = Array.from(
|
||||
contentRef.current?.querySelectorAll<HTMLElement>('[data-switcher-item-id]') ?? [],
|
||||
).find((element) => element.dataset.switcherItemId === initialTarget);
|
||||
if (!item) return;
|
||||
item.focus();
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
initialFocusCompleteRef.current = true;
|
||||
});
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [expandedParents, initialTarget, items]);
|
||||
|
||||
return (
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<div ref={contentRef} className="max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-0.5">
|
||||
<BaseMenu.Item
|
||||
data-switcher-item-id={NEW_SESSION_SWITCHER_TARGET}
|
||||
onClick={handleNewSession}
|
||||
className={cn(
|
||||
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
||||
@@ -227,6 +263,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx
|
||||
handleSelect();
|
||||
}}
|
||||
data-slot="session-switcher-item"
|
||||
data-switcher-item-id={session.id}
|
||||
className={cn(
|
||||
'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
||||
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Event } from '@opencode-ai/sdk/v2/client';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { deriveRecentSessions } from '../recent/activitySections';
|
||||
import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status';
|
||||
import { applyGlobalSessionStatusEvent, replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import {
|
||||
buildSidebarSessionProjection,
|
||||
getDescendantIds,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status';
|
||||
import { replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { useCollapsedSessionActivityState } from './collapsedActivityState';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
@@ -26,6 +26,21 @@ export const useSessionSearchEffects = ({
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [enabled, isSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
// The open_session_list shortcut lands here when the sidebar is visible:
|
||||
// the session list is already on screen, so the shortcut opens its search.
|
||||
React.useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handleOpenRequest = () => {
|
||||
setIsSessionSearchOpen(true);
|
||||
sessionSearchInputRef.current?.focus();
|
||||
sessionSearchInputRef.current?.select();
|
||||
};
|
||||
window.addEventListener('openchamber:sidebar-session-search', handleOpenRequest);
|
||||
return () => window.removeEventListener('openchamber:sidebar-session-search', handleOpenRequest);
|
||||
}, [enabled, setIsSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import {
|
||||
findSwitcherItemAncestorIds,
|
||||
selectSwitcherParents,
|
||||
type SwitcherItem,
|
||||
} from './useSwitcherItems';
|
||||
|
||||
const session = (id: string, options: { parentID?: string; archived?: boolean; projectId?: string } = {}): Session => ({
|
||||
id,
|
||||
parentID: options.parentID,
|
||||
time: options.archived ? { archived: Date.now() } : undefined,
|
||||
projectId: options.projectId ?? 'project-a',
|
||||
} as unknown as Session);
|
||||
|
||||
const selectParents = (sessions: Session[], currentSessionId: string | null, scopeProjectId: string | null = null): Session[] => (
|
||||
selectSwitcherParents(sessions, new Set(), new Map(), scopeProjectId, currentSessionId, (item) => (item as Session & { projectId: string }).projectId)
|
||||
);
|
||||
|
||||
describe('session switcher initial selection', () => {
|
||||
test('finds all local ancestors for a current child session', () => {
|
||||
const items: SwitcherItem[] = [{
|
||||
node: { session: session('root'), worktree: null, children: [{ session: session('parent', { parentID: 'root' }), worktree: null, children: [{ session: session('child', { parentID: 'parent' }), worktree: null, children: [] }] }] },
|
||||
projectId: 'project-a', groupDirectory: null, secondaryMeta: null,
|
||||
}];
|
||||
|
||||
expect(findSwitcherItemAncestorIds(items, 'child')).toEqual(['root', 'parent']);
|
||||
expect(findSwitcherItemAncestorIds(items, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
test('replaces the final recent slot with the current root and excludes invalid current sessions', () => {
|
||||
const roots = Array.from({ length: 8 }, (_, index) => session(`root-${index}`));
|
||||
const child = session('child', { parentID: 'root-7' });
|
||||
|
||||
expect(selectParents([...roots, child], 'child').map((item) => item.id)).toEqual([
|
||||
'root-0', 'root-1', 'root-2', 'root-3', 'root-4', 'root-5', 'root-7',
|
||||
]);
|
||||
expect(selectParents([...roots, child], 'missing').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||
expect(selectParents([...roots, child], 'child', 'project-b').map((item) => item.id)).toEqual([]);
|
||||
expect(selectParents([...roots.slice(0, 7), session('archived', { archived: true })], 'archived').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||
expect(selectParents([...roots, session('archived-child', { archived: true, parentID: 'root-7' })], 'archived-child').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const MAX_PARENT_SESSIONS = 7;
|
||||
|
||||
type SwitcherItemsOptions = {
|
||||
scopeProjectId?: string | null;
|
||||
currentSessionId?: string | null;
|
||||
/** How many parent sessions to return (default 7 — the desktop dropdown). */
|
||||
maxParents?: number;
|
||||
};
|
||||
@@ -46,8 +47,69 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n
|
||||
return segments[segments.length - 1] ?? null;
|
||||
};
|
||||
|
||||
export const findSwitcherItemAncestorIds = (items: SwitcherItem[], sessionId: string): string[] | null => {
|
||||
const visit = (node: SessionNode, ancestors: string[]): string[] | null => {
|
||||
if (node.session.id === sessionId) return ancestors;
|
||||
for (const child of node.children) {
|
||||
const result = visit(child, [...ancestors, node.session.id]);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const result = visit(item.node, []);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const selectSwitcherParents = (
|
||||
activeSessions: Session[],
|
||||
pinnedSessionIds: Set<string>,
|
||||
sessionOrderRanks: Map<string, number>,
|
||||
scopeProjectId: string | null,
|
||||
currentSessionId: string | null,
|
||||
getProjectId: (session: Session) => string | null,
|
||||
maxParents = MAX_PARENT_SESSIONS,
|
||||
isExcluded?: (session: Session) => boolean,
|
||||
): Session[] => {
|
||||
const sessionsById = new Map(activeSessions.map((session) => [session.id, session]));
|
||||
const isEligibleParent = (session: Session): boolean => {
|
||||
if (session.time?.archived) return false;
|
||||
if (isExcluded?.(session)) return false;
|
||||
// SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions.
|
||||
if ((session as Session & { parentID?: string | null }).parentID) return false;
|
||||
return !scopeProjectId || getProjectId(session) === scopeProjectId;
|
||||
};
|
||||
const parents = activeSessions
|
||||
.filter(isEligibleParent)
|
||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
||||
|
||||
const currentSession = currentSessionId ? sessionsById.get(currentSessionId) ?? null : null;
|
||||
let currentRoot: Session | null = currentSession?.time?.archived ? null : currentSession;
|
||||
const visited = new Set<string>();
|
||||
while (currentRoot) {
|
||||
// SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions.
|
||||
const parentId = (currentRoot as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentId) break;
|
||||
if (visited.has(parentId)) {
|
||||
currentRoot = null;
|
||||
break;
|
||||
}
|
||||
visited.add(parentId);
|
||||
currentRoot = sessionsById.get(parentId) ?? null;
|
||||
}
|
||||
|
||||
const currentRootIndex = currentRoot && isEligibleParent(currentRoot) ? parents.indexOf(currentRoot) : -1;
|
||||
if (currentRootIndex >= maxParents) {
|
||||
return [...parents.slice(0, Math.max(0, maxParents - 1)), currentRoot!];
|
||||
}
|
||||
return parents.slice(0, maxParents);
|
||||
};
|
||||
|
||||
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
|
||||
const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options;
|
||||
const { scopeProjectId = null, currentSessionId = null, maxParents = MAX_PARENT_SESSIONS } = options;
|
||||
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||
@@ -116,19 +178,17 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
||||
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
||||
});
|
||||
|
||||
const parents = activeSessions
|
||||
.filter((session) => !session.time?.archived)
|
||||
const parents = selectSwitcherParents(
|
||||
activeSessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
scopeProjectId,
|
||||
currentSessionId,
|
||||
(session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null,
|
||||
maxParents,
|
||||
// btw forks stay hidden until promoted to a full session
|
||||
.filter((session) => !isBtwSession(session))
|
||||
.filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session)))
|
||||
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
|
||||
.filter((session) => {
|
||||
if (!scopeProjectId) return true;
|
||||
const directory = resolveGlobalSessionDirectory(session);
|
||||
return findProjectForDirectory(directory)?.id === scopeProjectId;
|
||||
})
|
||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
|
||||
.slice(0, maxParents);
|
||||
(session) => isBtwSession(session) || (isVSCode && isChatDirectoryPath(resolveGlobalSessionDirectory(session))),
|
||||
);
|
||||
|
||||
const buildNode = (session: Session): SessionNode => {
|
||||
const childSessions = childrenByParent.get(session.id) ?? [];
|
||||
@@ -158,7 +218,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
|
||||
}, [activeSessions, branchesByDirectory, currentSessionId, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,8 @@ import { toast } from '@/components/ui';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts';
|
||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
||||
|
||||
@@ -49,6 +50,7 @@ import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
||||
import { truncatePathMiddle } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { buildCommandPaletteFileSearchKey, scoreCommandPaletteFiles } from './commandPaletteFilesState';
|
||||
|
||||
@@ -58,6 +60,9 @@ type CommandEntry = {
|
||||
icon: React.ReactNode;
|
||||
shortcutId?: string;
|
||||
searchText: string;
|
||||
/** Search-only command: reachable by typing, hidden from the initial list
|
||||
so the first screen stays scroll-free. */
|
||||
secondary?: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
@@ -89,9 +94,14 @@ export const CommandPalette: React.FC = () => {
|
||||
const openContextSurface = useUIStore((s) => s.openContextSurface);
|
||||
const openContextFile = useUIStore((s) => s.openContextFile);
|
||||
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
|
||||
const openMultiRunLauncher = useUIStore((s) => s.openMultiRunLauncher);
|
||||
const setArchivePageOpen = useUIStore((s) => s.setArchivePageOpen);
|
||||
const setProjectContextTab = useUIStore((s) => s.setProjectContextTab);
|
||||
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const togglePinnedSession = useSessionPinnedStore((s) => s.toggle);
|
||||
|
||||
const activeSessions = useGlobalSessionsStore(React.useCallback(
|
||||
(state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS,
|
||||
@@ -230,6 +240,27 @@ export const CommandPalette: React.FC = () => {
|
||||
if (currentDirectory) openContextOverview(currentDirectory);
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'cycle-theme',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.cycleTheme'),
|
||||
icon: <Icon name="palette" className="mr-2 h-4 w-4" />,
|
||||
shortcutId: 'cycle_theme',
|
||||
searchText: t('commandPalette.item.cycleTheme'),
|
||||
onSelect: run(() => {
|
||||
shortcutRegistry.invoke('cycle_theme');
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open-status',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.showOpenCodeStatus'),
|
||||
icon: <Icon name="pulse" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.showOpenCodeStatus'),
|
||||
onSelect: run(() => {
|
||||
void showOpenCodeStatus();
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open-settings',
|
||||
title: t('commandPalette.item.openSettings'),
|
||||
@@ -239,6 +270,97 @@ export const CommandPalette: React.FC = () => {
|
||||
onSelect: run(() => setSettingsDialogOpen(true)),
|
||||
},
|
||||
];
|
||||
list.push(
|
||||
{
|
||||
id: 'pin-session',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.pinSession'),
|
||||
icon: <Icon name="pushpin" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.pinSession'),
|
||||
onSelect: run(() => {
|
||||
if (currentSessionId && currentDirectory) {
|
||||
togglePinnedSession({ directory: currentDirectory, sessionId: currentSessionId });
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'copy-session-id',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.copySessionId'),
|
||||
icon: <Icon name="file-copy" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.copySessionId'),
|
||||
onSelect: run(() => {
|
||||
if (!currentSessionId) return;
|
||||
void copyTextToClipboard(currentSessionId)
|
||||
.then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success(t('sessions.sidebar.session.copyId.success'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('sessions.sidebar.session.copyId.error'));
|
||||
})
|
||||
.catch(() => toast.error(t('sessions.sidebar.session.copyId.error')));
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open-multi-run',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.openMultiRun'),
|
||||
icon: <Icon name="checkbox-multiple" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.openMultiRun'),
|
||||
onSelect: run(() => {
|
||||
setSessionSwitcherOpen(false);
|
||||
openMultiRunLauncher();
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open-archive',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.openArchive'),
|
||||
icon: <Icon name="archive" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.openArchive'),
|
||||
onSelect: run(() => {
|
||||
setSessionSwitcherOpen(false);
|
||||
setArchivePageOpen(true);
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open-notes',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.openNotes'),
|
||||
icon: <Icon name="sticky-note" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.openNotes'),
|
||||
onSelect: run(() => {
|
||||
if (currentDirectory) {
|
||||
setProjectContextTab('notes');
|
||||
openContextSurface(currentDirectory, 'notes');
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open-todos',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.openTodos'),
|
||||
icon: <Icon name="checkbox-circle" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.openTodos'),
|
||||
onSelect: run(() => {
|
||||
if (currentDirectory) {
|
||||
setProjectContextTab('todos');
|
||||
openContextSurface(currentDirectory, 'notes');
|
||||
}
|
||||
}),
|
||||
},
|
||||
);
|
||||
list.push({
|
||||
id: 'toggle-memory-debug',
|
||||
secondary: true,
|
||||
title: t('commandPalette.item.toggleMemoryDebug'),
|
||||
icon: <Icon name="bug" className="mr-2 h-4 w-4" />,
|
||||
searchText: t('commandPalette.item.toggleMemoryDebug'),
|
||||
onSelect: run(() => {
|
||||
window.dispatchEvent(new CustomEvent('openchamber:memory-debug-toggle'));
|
||||
}),
|
||||
});
|
||||
if (canUseElectronDesktopIPC()) {
|
||||
list.splice(1, 0, {
|
||||
id: 'new-mini-chat',
|
||||
@@ -270,6 +392,11 @@ export const CommandPalette: React.FC = () => {
|
||||
setSettingsDialogOpen,
|
||||
activeProject?.id,
|
||||
activeProject?.path,
|
||||
currentSessionId,
|
||||
togglePinnedSession,
|
||||
openMultiRunLauncher,
|
||||
setArchivePageOpen,
|
||||
setProjectContextTab,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -378,7 +505,9 @@ export const CommandPalette: React.FC = () => {
|
||||
const hasQuery = liveTrimmed.length > 0;
|
||||
|
||||
const scoredCommands = React.useMemo(() => {
|
||||
if (!hasQuery) return commands.map((item) => ({ item, score: 0 }));
|
||||
if (!hasQuery) {
|
||||
return commands.filter((item) => !item.secondary).map((item) => ({ item, score: 0 }));
|
||||
}
|
||||
return scoreByFuzzyQuery(commands, liveTrimmed, (c) => c.searchText, {
|
||||
limit: 7,
|
||||
noFuzzy: true,
|
||||
|
||||
@@ -10,18 +10,19 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import {
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
getShortcutAction,
|
||||
getModifierLabel,
|
||||
formatShortcutForDisplay,
|
||||
type ShortcutActionId,
|
||||
} from "@/lib/shortcuts";
|
||||
import { useI18n, type I18nKey } from "@/lib/i18n";
|
||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
|
||||
type ShortcutItem = {
|
||||
id?: string;
|
||||
id?: ShortcutActionId;
|
||||
keys: string | string[];
|
||||
descriptionKey: I18nKey;
|
||||
descriptionKey?: I18nKey;
|
||||
icon: IconName | null;
|
||||
};
|
||||
|
||||
@@ -30,9 +31,12 @@ type ShortcutSection = {
|
||||
items: ShortcutItem[];
|
||||
};
|
||||
|
||||
const renderShortcut = (id: string, fallbackCombo: string, overrides: Record<string, string>) => {
|
||||
const action = getShortcutAction(id);
|
||||
return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo;
|
||||
const renderShortcut = (
|
||||
id: ShortcutActionId,
|
||||
overrides: Record<string, string>,
|
||||
unassignedLabel: string,
|
||||
) => {
|
||||
return formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel);
|
||||
};
|
||||
|
||||
export const HelpDialog: React.FC = () => {
|
||||
@@ -40,7 +44,6 @@ export const HelpDialog: React.FC = () => {
|
||||
const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen);
|
||||
const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const mod = getModifierLabel();
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
|
||||
const shortcuts: ShortcutSection[] = [
|
||||
@@ -100,7 +103,7 @@ export const HelpDialog: React.FC = () => {
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`Shift + Alt + ${mod} + N`],
|
||||
keys: [formatShortcutForDisplay('mod+shift+alt+n')],
|
||||
descriptionKey: "helpDialog.item.newWindow",
|
||||
icon: "window",
|
||||
},
|
||||
@@ -121,6 +124,21 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: "git-branch",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_draft_project_picker',
|
||||
icon: 'folder',
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_draft_worktree_picker',
|
||||
icon: 'git-branch',
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_session_list',
|
||||
icon: 'list-unordered',
|
||||
keys: '',
|
||||
},
|
||||
{ id: 'focus_input', descriptionKey: "helpDialog.item.focusChatInput", icon: "text", keys: '' },
|
||||
{
|
||||
id: 'toggle_prompt_navigator',
|
||||
@@ -139,24 +157,6 @@ export const HelpDialog: React.FC = () => {
|
||||
{
|
||||
categoryKey: "helpDialog.section.panels",
|
||||
items: [
|
||||
{
|
||||
id: 'toggle_right_sidebar',
|
||||
descriptionKey: 'helpDialog.item.toggleRightSidebar',
|
||||
icon: "layout-right",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_right_sidebar_git',
|
||||
descriptionKey: 'helpDialog.item.openRightSidebarGitTab',
|
||||
icon: "git-branch",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_right_sidebar_files',
|
||||
descriptionKey: 'helpDialog.item.openRightSidebarFilesTab',
|
||||
icon: "layout-right",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'toggle_terminal',
|
||||
descriptionKey: 'helpDialog.item.toggleTerminalDock',
|
||||
@@ -170,14 +170,13 @@ export const HelpDialog: React.FC = () => {
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'toggle_context_plan',
|
||||
descriptionKey: 'helpDialog.item.togglePlanContextPanel',
|
||||
icon: "time",
|
||||
keys: '',
|
||||
keys: [`${formatShortcutForDisplay(getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides))} + 1...0`],
|
||||
descriptionKey: "helpDialog.item.switchContextSurface",
|
||||
icon: "layout-right",
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + 1...0`],
|
||||
descriptionKey: "helpDialog.item.switchContextSurface",
|
||||
keys: [`${formatShortcutForDisplay(getEffectiveShortcutPrefix('switch_session_tab', shortcutOverrides))} + 1...9`],
|
||||
descriptionKey: "helpDialog.item.switchSessionTab",
|
||||
icon: "layout-right",
|
||||
},
|
||||
],
|
||||
@@ -197,12 +196,6 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: "stack",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'cycle_services_tab',
|
||||
descriptionKey: 'helpDialog.item.cycleServicesTab',
|
||||
icon: "stack",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_settings',
|
||||
descriptionKey: "helpDialog.item.openSettings",
|
||||
@@ -214,7 +207,7 @@ export const HelpDialog: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isHelpDialogOpen} onOpenChange={setHelpDialogOpen}>
|
||||
<Dialog open={isHelpDialogOpen} onOpenChange={setHelpDialogOpen}>
|
||||
<DialogContent className="max-w-2xl w-[min(42rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -237,40 +230,54 @@ export const HelpDialog: React.FC = () => {
|
||||
{section.items
|
||||
.filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator'))
|
||||
.map((shortcut) => {
|
||||
const displayKeys = shortcut.id
|
||||
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
|
||||
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
|
||||
const action = shortcut.id ? getShortcutAction(shortcut.id) : undefined;
|
||||
const descriptionKey = shortcut.descriptionKey
|
||||
?? (action?.customizable ? action.settingsLabelKey : undefined);
|
||||
if (!descriptionKey) return null;
|
||||
// This dialog lists what the keyboard can do right now;
|
||||
// an action without a binding belongs to the command
|
||||
// palette and Settings, not here.
|
||||
if (shortcut.id && !getEffectiveShortcutCombo(shortcut.id, shortcutOverrides)) {
|
||||
return null;
|
||||
}
|
||||
const displayKeys = shortcut.id
|
||||
? renderShortcut(
|
||||
shortcut.id,
|
||||
shortcutOverrides,
|
||||
t('settings.openchamber.keyboardShortcuts.unassigned'),
|
||||
)
|
||||
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={shortcut.id || shortcut.descriptionKey}
|
||||
className="flex items-center justify-between py-1 px-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{shortcut.icon && (
|
||||
<Icon name={shortcut.icon} className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-meta">
|
||||
{t(shortcut.descriptionKey)}
|
||||
</span>
|
||||
return (
|
||||
<div
|
||||
key={shortcut.id || descriptionKey}
|
||||
className="flex items-center justify-between py-1 px-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{shortcut.icon && (
|
||||
<Icon name={shortcut.icon} className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-meta">
|
||||
{t(descriptionKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => (
|
||||
<React.Fragment key={`${keyCombo}-${i}`}>
|
||||
{i > 0 && (
|
||||
<span className="typography-meta text-muted-foreground mx-1">
|
||||
{t('helpDialog.keyCombiner.or')}
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
|
||||
{keyCombo}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => (
|
||||
<React.Fragment key={`${keyCombo}-${i}`}>
|
||||
{i > 0 && (
|
||||
<span className="typography-meta text-muted-foreground mx-1">
|
||||
{t('helpDialog.keyCombiner.or')}
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
|
||||
{keyCombo}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -284,14 +291,18 @@ export const HelpDialog: React.FC = () => {
|
||||
<ul className="space-y-0.5 typography-meta">
|
||||
<li>
|
||||
• {t('helpDialog.proTips.commandPalette', {
|
||||
shortcut: renderShortcut('open_command_palette', `${mod} P`, shortcutOverrides),
|
||||
shortcut: renderShortcut(
|
||||
'open_command_palette',
|
||||
shortcutOverrides,
|
||||
t('settings.openchamber.keyboardShortcuts.unassigned'),
|
||||
),
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
• {t('helpDialog.proTips.recentSessions')}
|
||||
</li>
|
||||
<li>
|
||||
• {t('helpDialog.proTips.themeCycling')}
|
||||
• {t('helpDialog.proTips.leaderSequences')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { shortcutRegistry } from "@/lib/shortcuts";
|
||||
import { handleDropdownNavigationKey } from "./dropdown-navigation";
|
||||
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles";
|
||||
|
||||
type AsChildProps = { asChild?: boolean };
|
||||
@@ -34,11 +36,21 @@ function renderFromAsChild(asChild: boolean | undefined, children: React.ReactNo
|
||||
return { children };
|
||||
}
|
||||
|
||||
type DropdownMenuProps = React.ComponentProps<typeof BaseMenu.Root> & {
|
||||
disableGlobalShortcuts?: boolean;
|
||||
};
|
||||
|
||||
function DropdownMenu({
|
||||
disableGlobalShortcuts = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseMenu.Root>) {
|
||||
}: DropdownMenuProps) {
|
||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||
const [collisionBoundary, setCollisionBoundary] = React.useState<Element | null>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
|
||||
const isOpen = open ?? uncontrolledOpen;
|
||||
const portalContextValue = React.useMemo<DropdownPortalContextValue>(() => ({
|
||||
portalContainer,
|
||||
collisionBoundary,
|
||||
@@ -46,9 +58,24 @@ function DropdownMenu({
|
||||
setCollisionBoundary,
|
||||
}), [collisionBoundary, portalContainer]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!disableGlobalShortcuts || !isOpen) return;
|
||||
return shortcutRegistry.suspend();
|
||||
}, [disableGlobalShortcuts, isOpen]);
|
||||
|
||||
const handleOpenChange: NonNullable<React.ComponentProps<typeof BaseMenu.Root>['onOpenChange']> = (nextOpen, eventDetails) => {
|
||||
if (open === undefined) setUncontrolledOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen, eventDetails);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownPortalContext.Provider value={portalContextValue}>
|
||||
<BaseMenu.Root {...props} />
|
||||
<BaseMenu.Root
|
||||
{...props}
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
</DropdownPortalContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -116,11 +143,23 @@ function DropdownMenuContent({
|
||||
style,
|
||||
children,
|
||||
onCloseAutoFocus,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: ContentProps) {
|
||||
const portalContext = React.useContext(DropdownPortalContext);
|
||||
void onCloseAutoFocus
|
||||
|
||||
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['onKeyDown']> = (event) => {
|
||||
onKeyDown?.(event);
|
||||
handleDropdownNavigationKey(event, (navigationKey) => {
|
||||
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: navigationKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseMenu.Portal container={portalToBody ? undefined : portalContext?.portalContainer || undefined}>
|
||||
<BaseMenu.Positioner
|
||||
@@ -143,6 +182,7 @@ function DropdownMenuContent({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Popup>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type React from 'react';
|
||||
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
|
||||
function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
|
||||
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
|
||||
// `code` covers non-Latin layouts, where `key` is the layout's own letter.
|
||||
if (event.key.toLowerCase() === 'n' || event.code === 'KeyN') return 'ArrowDown';
|
||||
if (event.key.toLowerCase() === 'p' || event.code === 'KeyP') return 'ArrowUp';
|
||||
return null;
|
||||
}
|
||||
|
||||
type DropdownNavigationEvent = Pick<
|
||||
React.KeyboardEvent<HTMLElement>,
|
||||
| 'altKey'
|
||||
| 'code'
|
||||
| 'ctrlKey'
|
||||
| 'defaultPrevented'
|
||||
| 'isPropagationStopped'
|
||||
| 'key'
|
||||
| 'metaKey'
|
||||
| 'preventDefault'
|
||||
| 'shiftKey'
|
||||
| 'stopPropagation'
|
||||
>;
|
||||
|
||||
export function handleDropdownNavigationKey(
|
||||
event: DropdownNavigationEvent,
|
||||
navigate: (key: 'ArrowDown' | 'ArrowUp') => void,
|
||||
): boolean {
|
||||
if (event.defaultPrevented || event.isPropagationStopped()) return false;
|
||||
const navigationKey = getDropdownNavigationKey(event);
|
||||
if (!navigationKey) return false;
|
||||
|
||||
// Do not add an IME guard: exact Ctrl+N/P remain intentional commands, while
|
||||
// every other composing key falls through without being handled.
|
||||
navigate(navigationKey);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldDismissDropdown(
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
): boolean {
|
||||
return event.key === 'Escape' && !isIMECompositionEvent(event);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { cn } from "@/lib/utils"
|
||||
import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger"
|
||||
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { shortcutRegistry } from "@/lib/shortcuts";
|
||||
import { handleDropdownNavigationKey } from "./dropdown-navigation";
|
||||
|
||||
type AsChildProps = { asChild?: boolean };
|
||||
type AsChildRenderProps = {
|
||||
@@ -38,15 +40,22 @@ type SelectRootProps<Value extends string = string> = Omit<
|
||||
value?: Value;
|
||||
defaultValue?: Value;
|
||||
onValueChange?: (value: Value, eventDetails: SelectRootChangeEventDetails) => void;
|
||||
disableGlobalShortcuts?: boolean;
|
||||
};
|
||||
|
||||
function Select<Value extends string = string>({
|
||||
onValueChange,
|
||||
modal = false,
|
||||
disableGlobalShortcuts = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: SelectRootProps<Value>) {
|
||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||
const [collisionBoundary, setCollisionBoundary] = React.useState<Element | null>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
|
||||
const isOpen = open ?? uncontrolledOpen;
|
||||
const portalContextValue = React.useMemo<SelectPortalContextValue>(() => ({
|
||||
portalContainer,
|
||||
collisionBoundary,
|
||||
@@ -63,9 +72,26 @@ function Select<Value extends string = string>({
|
||||
[onValueChange]
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!disableGlobalShortcuts || !isOpen) return;
|
||||
return shortcutRegistry.suspend();
|
||||
}, [disableGlobalShortcuts, isOpen]);
|
||||
|
||||
const handleOpenChange: NonNullable<React.ComponentProps<typeof BaseSelect.Root>['onOpenChange']> = (nextOpen, eventDetails) => {
|
||||
if (open === undefined) setUncontrolledOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen, eventDetails);
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectPortalContext.Provider value={portalContextValue}>
|
||||
<BaseSelect.Root {...props} modal={modal} onValueChange={handleValueChange} />
|
||||
<BaseSelect.Root
|
||||
{...props}
|
||||
modal={modal}
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
</SelectPortalContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -184,12 +210,24 @@ function SelectContent({
|
||||
align,
|
||||
collisionAvoidance,
|
||||
constrainToMain = false,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSelect.Popup> & SelectContentExtra) {
|
||||
const portalContext = React.useContext(SelectPortalContext);
|
||||
const alignItemWithTrigger = position === "item-aligned";
|
||||
const portalContainer = portalContext?.portalContainer ?? null;
|
||||
|
||||
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseSelect.Popup>['onKeyDown']> = (event) => {
|
||||
onKeyDown?.(event);
|
||||
handleDropdownNavigationKey(event, (navigationKey) => {
|
||||
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: navigationKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSelect.Portal container={portalToBody ? undefined : portalContainer || undefined}>
|
||||
<BaseSelect.Positioner
|
||||
@@ -214,6 +252,7 @@ function SelectContent({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
outerClassName={cn(
|
||||
@@ -253,13 +292,17 @@ function SelectLabel({
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
showSelectedBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSelect.Item>) {
|
||||
}: React.ComponentProps<typeof BaseSelect.Item> & {
|
||||
showSelectedBackground?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BaseSelect.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover data-[selected]:bg-interactive-selection data-[selected]:text-interactive-selection-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 typography-ui-label outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 typography-ui-label outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
showSelectedBackground && "data-[selected]:bg-interactive-selection data-[selected]:text-interactive-selection-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1768,6 +1768,37 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
scrollToFile(value);
|
||||
}, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]);
|
||||
|
||||
// Step review to the adjacent changed file (alt+arrow): selects, expands
|
||||
// a collapsed section, and scrolls to it. Window-level because the diff
|
||||
// surface has no persistent focus target; guarded off editable fields.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && (
|
||||
target.isContentEditable
|
||||
|| target.tagName === 'INPUT'
|
||||
|| target.tagName === 'TEXTAREA'
|
||||
|| target.closest('[role="dialog"]')
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (changedFiles.length === 0) return;
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const index = displayFile ? changedFiles.findIndex((file) => file.path === displayFile) : -1;
|
||||
const nextIndex = index === -1
|
||||
? (delta > 0 ? 0 : changedFiles.length - 1)
|
||||
: index + delta;
|
||||
const next = changedFiles[nextIndex];
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
handleSelectFileAndScroll(next.path);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [changedFiles, displayFile, handleSelectFileAndScroll]);
|
||||
|
||||
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
|
||||
const nextLayout: 'inline' | 'side-by-side' =
|
||||
mode === 'side-by-side' ? 'side-by-side' : 'inline';
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
||||
import { cn, getRevealLabelKey } from '@/lib/utils';
|
||||
import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers';
|
||||
import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
@@ -75,7 +75,8 @@ import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
|
||||
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { useKeybind, useKeybinds } from '@/hooks/useKeybind';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi';
|
||||
@@ -1032,7 +1033,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
|
||||
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
|
||||
const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap);
|
||||
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
@@ -1759,35 +1759,28 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
setAutoSaveStatus('idle');
|
||||
}, [selectedFile?.path]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!hasModifier(e)) {
|
||||
return;
|
||||
}
|
||||
useKeybinds({
|
||||
save_file: (event) => {
|
||||
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
|
||||
|
||||
if (e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
// Cancel pending auto-save; user wants immediate save
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
if (!isSaving) {
|
||||
void saveDraft().then((saved) => {
|
||||
if (!saved) return;
|
||||
setAutoSaveStatus('saved');
|
||||
setTimeout(() => setAutoSaveStatus('idle'), 2000);
|
||||
});
|
||||
}
|
||||
} else if (e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault();
|
||||
setIsSearchOpen(true);
|
||||
// Cancel pending auto-save because the explicit save should run immediately.
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isSaving, saveDraft]);
|
||||
if (!isSaving) {
|
||||
void saveDraft().then((saved) => {
|
||||
if (!saved) return;
|
||||
setAutoSaveStatus('saved');
|
||||
setTimeout(() => setAutoSaveStatus('idle'), 2000);
|
||||
});
|
||||
}
|
||||
},
|
||||
find_in_file: (event) => {
|
||||
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
|
||||
setIsSearchOpen(true);
|
||||
},
|
||||
});
|
||||
|
||||
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
|
||||
const loadId = activeFileLoadIdRef.current + 1;
|
||||
@@ -2906,42 +2899,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
};
|
||||
}, [isMobile, nudgeEditorSelectionAboveKeyboard]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useKeybind('open_go_to_line', (event) => {
|
||||
if (!canEdit || textViewMode !== 'edit' || isMobile) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides);
|
||||
const target = event.target as Element | null;
|
||||
if (target?.closest('[role="dialog"]')) return false;
|
||||
if (!(target instanceof Node) || !editorWrapperRef.current?.contains(target)) return false;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target?.closest('[role="dialog"]')) {
|
||||
return;
|
||||
}
|
||||
const isEditorTarget = Boolean(target?.closest('.cm-editor'));
|
||||
const isTypingTarget = Boolean(target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]'));
|
||||
if (isTypingTarget && !isEditorTarget) return false;
|
||||
|
||||
const isEditorTarget = Boolean(target?.closest('.cm-editor'));
|
||||
const isTypingTarget = Boolean(
|
||||
target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]')
|
||||
);
|
||||
if (isTypingTarget && !isEditorTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.activeElement as Element | null;
|
||||
const editorHasFocus = Boolean(activeElement?.closest('.cm-editor'));
|
||||
if (!editorHasFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(event, goToLineCombo)) {
|
||||
event.preventDefault();
|
||||
setIsGoToLineOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [canEdit, isMobile, shortcutOverrides, textViewMode]);
|
||||
setIsGoToLineOpen(true);
|
||||
});
|
||||
|
||||
const editorFontSize = useUIStore((state) => state.editorFontSize);
|
||||
|
||||
@@ -3196,6 +3168,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
|
||||
const docked = layout === 'docked';
|
||||
const saveShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('save_file'));
|
||||
const wrapperCls = docked
|
||||
? 'pointer-events-auto flex flex-wrap items-center gap-1'
|
||||
: 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm';
|
||||
@@ -3225,14 +3198,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
<Icon name="check" className="size-3.5" />
|
||||
{t('filesView.editor.saved')}
|
||||
</span>
|
||||
) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }),
|
||||
) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut }),
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void saveDraft()}
|
||||
className="h-6 gap-1 px-1 text-muted-foreground opacity-80 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent"
|
||||
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` })}
|
||||
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })}
|
||||
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut })}
|
||||
aria-label={t('filesView.editor.saveAria', { shortcut: saveShortcut })}
|
||||
>
|
||||
<Icon name="save-3" className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React from 'react';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getEffectiveShortcutCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -187,6 +191,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const settingsPageRaw = useUIStore((state) => state.settingsPage);
|
||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings);
|
||||
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
|
||||
|
||||
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
|
||||
@@ -728,7 +733,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: showBackButton
|
||||
? t('settings.view.actions.backToSettings')
|
||||
: t('settings.view.actions.closeSettings');
|
||||
const shortcutKey = getModifierLabel();
|
||||
const openSettingsCombo = getEffectiveShortcutCombo(
|
||||
'open_settings',
|
||||
openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride },
|
||||
);
|
||||
const closeSettingsTitle = openSettingsCombo
|
||||
? t('settings.view.actions.closeSettingsWithShortcut', {
|
||||
shortcut: formatShortcutForDisplay(openSettingsCombo),
|
||||
})
|
||||
: t('settings.view.actions.closeSettings');
|
||||
|
||||
const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => {
|
||||
if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
|
||||
@@ -1077,7 +1090,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('settings.view.actions.closeSettings')}
|
||||
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
|
||||
title={closeSettingsTitle}
|
||||
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Icon name="close" className="h-5 w-5" />
|
||||
@@ -1105,7 +1118,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('settings.view.actions.closeSettings')}
|
||||
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
|
||||
title={closeSettingsTitle}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Icon name="close" className="h-5 w-5" />
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
type TerminalViewProps = {
|
||||
visible?: boolean;
|
||||
@@ -968,7 +969,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
onClick={() => handleModifierToggle('ctrl')}
|
||||
disabled={quickKeysDisabled}
|
||||
>
|
||||
<span className="text-xs font-medium">{t('terminalView.quickKeys.controlLabel')}</span>
|
||||
<span className="text-xs font-medium">{formatShortcutForDisplay('ctrl')}</span>
|
||||
<span className="sr-only">{t('terminalView.quickKeys.controlModifierAria')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -981,7 +982,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
onClick={() => handleModifierToggle('alt')}
|
||||
disabled={quickKeysDisabled}
|
||||
>
|
||||
<span className="text-xs font-medium">{t('terminalView.quickKeys.altLabel')}</span>
|
||||
<span className="text-xs font-medium">{formatShortcutForDisplay('alt')}</span>
|
||||
<span className="sr-only">{t('terminalView.quickKeys.altModifierAria')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
interface CommitInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: () => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
hasTouchInput?: boolean;
|
||||
@@ -18,6 +19,7 @@ const MAX_HEIGHT = 200;
|
||||
export const CommitInput: React.FC<CommitInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
hasTouchInput = false,
|
||||
@@ -58,6 +60,12 @@ export const CommitInput: React.FC<CommitInputProps> = ({
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
onSubmit?.();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder ?? t('gitView.commit.messagePlaceholder')}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -68,6 +68,9 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
<CommitInput
|
||||
value={commitMessage}
|
||||
onChange={onCommitMessageChange}
|
||||
onSubmit={() => {
|
||||
if (canCommit && !isGeneratingMessage) onCommit();
|
||||
}}
|
||||
placeholder={t('gitView.commit.messagePlaceholder')}
|
||||
disabled={commitAction !== null}
|
||||
hasTouchInput={hasTouchInput}
|
||||
|
||||
Reference in New Issue
Block a user