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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 1 addition & 20 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts";
import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
import {
DEFAULT_KEYBINDINGS,
DEFAULT_RESOLVED_KEYBINDINGS,
mergeWithDefaultKeybindings,
compileResolvedKeybindingRule,
compileResolvedKeybindingsConfig,
parseKeybindingShortcut,
Expand Down Expand Up @@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {
};
}

function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
if (custom.length === 0) {
return [...DEFAULT_RESOLVED_KEYBINDINGS];
}

const overriddenCommands = new Set(custom.map((binding) => binding.command));
const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
(binding) => !overriddenCommands.has(binding.command),
);
const merged = [...retainedDefaults, ...custom];

if (merged.length <= MAX_KEYBINDINGS_COUNT) {
return merged;
}

// Keep the latest rules when the config exceeds max size; later rules have higher precedence.
return merged.slice(-MAX_KEYBINDINGS_COUNT);
}

/**
* Keybindings - Service tag for keybinding configuration operations.
*/
Expand Down
49 changes: 47 additions & 2 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ import {
HistoryIcon,
ScaleIcon,
} from "lucide-react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
type Ref,
memo,
useImperativeHandle,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";

import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
Expand All @@ -25,7 +35,10 @@ import {
resolvePreviousWorktreeSeed,
shouldShowEnvironmentIndicator,
} from "./BranchToolbar.logic";
import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
import {
BranchToolbarBranchSelector,
type BranchToolbarBranchSelectorHandle,
} from "./BranchToolbarBranchSelector";
import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
import { Button } from "./ui/button";
Expand All @@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe
import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
import { cn } from "~/lib/utils";

export interface BranchToolbarHandle {
openBranchPicker: () => void;
usePreviousWorktree: () => void;
}

interface BranchToolbarProps {
ref?: Ref<BranchToolbarHandle>;
environmentId: EnvironmentId;
threadId: ThreadId;
showGitControls: boolean;
Expand Down Expand Up @@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
render={<Button variant="ghost" size="xs" />}
className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80"
data-composer-context-control
data-composer-shortcut={[
showEnvironmentPicker && !envLocked ? "composer.host" : "",
!envModeLocked ? "composer.workspace" : "",
].join(" ")}
>
{triggerContent}
<ChevronDownIcon className="size-3 shrink-0 opacity-50" />
Expand Down Expand Up @@ -438,6 +461,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
}

export const BranchToolbar = memo(function BranchToolbar({
ref,
environmentId,
threadId,
showGitControls,
Expand All @@ -458,6 +482,7 @@ export const BranchToolbar = memo(function BranchToolbar({
composerControlsHostRef,
contextStripVisible = true,
}: BranchToolbarProps) {
const branchSelectorRef = useRef<BranchToolbarBranchSelectorHandle>(null);
const threadRef = useMemo(
() => scopeThreadRef(environmentId, threadId),
[environmentId, threadId],
Expand Down Expand Up @@ -518,6 +543,25 @@ export const BranchToolbar = memo(function BranchToolbar({
});
}, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]);

useImperativeHandle(
ref,
() => ({
openBranchPicker: () => branchSelectorRef.current?.open(),
usePreviousWorktree: () => {
if (!showGitControls || !canUsePreviousWorktree || !previousWorktreeSeed) return;
onUsePreviousWorktree();
onComposerFocusRequest?.();
},
}),
[
canUsePreviousWorktree,
onComposerFocusRequest,
onUsePreviousWorktree,
previousWorktreeSeed,
showGitControls,
],
);

const showEnvironmentPicker = Boolean(
availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,
);
Expand Down Expand Up @@ -618,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({

{showGitControls ? (
<BranchToolbarBranchSelector
ref={branchSelectorRef}
className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"
environmentId={environmentId}
threadId={threadId}
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import {
useDeferredValue,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type MouseEvent as ReactMouseEvent,
type Ref,
} from "react";

import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
Expand Down Expand Up @@ -72,7 +74,12 @@ import {
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

export interface BranchToolbarBranchSelectorHandle {
open: () => void;
}

interface BranchToolbarBranchSelectorProps {
ref?: Ref<BranchToolbarBranchSelectorHandle>;
className?: string;
environmentId: EnvironmentId;
threadId: ThreadId;
Expand All @@ -92,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string {
}

export function BranchToolbarBranchSelector({
ref,
className,
environmentId,
threadId,
Expand Down Expand Up @@ -535,6 +543,17 @@ export function BranchToolbarBranchSelector({
}
}, []);

useImperativeHandle(
ref,
() => ({
open: () => {
if (isInitialBranchesLoadPending || isBranchActionPending) return;
handleOpenChange(true);
},
}),
[handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending],
);

const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false);
const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false);
const fetchNextBranchPage = useCallback(() => {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/BranchToolbarEnvModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
size="xs"
className="min-w-0 shrink font-normal text-xs!"
aria-label="Workspace"
data-composer-shortcut="composer.workspace"
data-composer-context-control
>
{effectiveEnvMode === "worktree" ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
size="xs"
className="min-w-0 max-w-full font-normal text-xs!"
aria-label="Run on"
data-composer-shortcut="composer.host"
data-composer-context-control
>
{autoEnvironmentLabel ? (
Expand Down
57 changes: 47 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ import {
deriveAgentPanelModel,
foldSubagentActivities,
} from "@t3tools/client-runtime/state/subagentRuntime";
import { BranchToolbar } from "./BranchToolbar";
import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
import {
Expand Down Expand Up @@ -1611,6 +1611,7 @@ export default function ChatView(props: ChatViewProps) {
const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
const localComposerRef = useRef<ChatComposerHandle | null>(null);
const composerRef = useComposerHandleContext() ?? localComposerRef;
const branchToolbarRef = useRef<BranchToolbarHandle>(null);
const pasteAsTextShortcutUntilRef = useRef(0);
const [restingComposerControlsHost, setRestingComposerControlsHost] =
useState<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -6267,6 +6268,17 @@ export default function ChatView(props: ChatViewProps) {
terminalUiOpenByThreadRef.current[activeThreadKey] = current;
}, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);

const getShortcutContext = useCallback(
() => ({
terminalFocus: getTerminalFocusOwner() !== null,
terminalOpen: Boolean(terminalUiState.terminalOpen),
previewFocus: isPreviewFocused(),
previewOpen: previewPanelOpen,
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
}),
[composerRef, previewPanelOpen, terminalUiState.terminalOpen],
);

useEffect(() => {
const handler = (event: globalThis.KeyboardEvent) => {
if (preventRepeatedTerminalCloseShortcut(event, keybindings)) {
Expand All @@ -6287,13 +6299,7 @@ export default function ChatView(props: ChatViewProps) {
if (event.defaultPrevented && terminalFocusOwner === null) {
return;
}
const shortcutContext = {
terminalFocus: terminalFocusOwner !== null,
terminalOpen: Boolean(terminalUiState.terminalOpen),
previewFocus: isPreviewFocused(),
previewOpen: previewPanelOpen,
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
};
const shortcutContext = getShortcutContext();

if (
!shortcutContext.terminalFocus &&
Expand Down Expand Up @@ -6458,7 +6464,33 @@ export default function ChatView(props: ChatViewProps) {
if (command === "modelPicker.toggle") {
event.preventDefault();
event.stopPropagation();
composerRef.current?.toggleModelPicker();
if (!event.repeat) composerRef.current?.toggleModelPicker();
return;
}

if (
command === "composer.host" ||
command === "composer.effort" ||
command === "composer.mode" ||
command === "composer.workspace"
) {
event.preventDefault();
event.stopPropagation();
if (!event.repeat) composerRef.current?.openControl(command);
return;
}

if (command === "composer.branch") {
event.preventDefault();
event.stopPropagation();
if (!event.repeat) branchToolbarRef.current?.openBranchPicker();
return;
}

if (command === "composer.previousWorktree") {
event.preventDefault();
event.stopPropagation();
if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree();
return;
}

Expand Down Expand Up @@ -6513,7 +6545,7 @@ export default function ChatView(props: ChatViewProps) {
supportsSettlement,
confirmAndUnpinThread,
copyActiveThreadReference,
previewPanelOpen,
getShortcutContext,
toggleRightPanel,
toggleRightPanelMaximized,
toggleTerminalVisibility,
Expand Down Expand Up @@ -8509,6 +8541,10 @@ export default function ChatView(props: ChatViewProps) {
// reader's feet. A link the agent wrote can open any other one here, and that one has to be
// checkable out like it is anywhere else.
<PullRequestDetailPanel
getShortcutContext={getShortcutContext}
shortcutsEnabled={
rightPanelOpen && activeRightPanelSurface?.id === renderedRightPanelSurface.id
}
key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
environmentId={activeThread.environmentId}
onSelectPullRequest={(reference) => {
Expand Down Expand Up @@ -9028,6 +9064,7 @@ export default function ChatView(props: ChatViewProps) {
{mountComposerContextStrip && (
<div className="pointer-events-auto">
<BranchToolbar
ref={branchToolbarRef}
environmentId={activeThread.environmentId}
threadId={activeThread.id}
showGitControls={isGitRepo}
Expand Down
27 changes: 26 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../../questionAttachments";
import type {
ApprovalRequestId,
KeybindingCommand,
AssistantCitation,
ChatFileAttachment,
EnvironmentId,
Expand Down Expand Up @@ -62,7 +63,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import { createPortal, flushSync } from "react-dom";
import {
clampCollapsedComposerCursor,
type ComposerSubmissionIntent,
Expand Down Expand Up @@ -1102,6 +1103,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
<TooltipTrigger
render={
<ComposerSelectControl
data-composer-shortcut="composer.mode"
size={size}
className={size === "xs" ? undefined : "font-medium"}
aria-label="Runtime mode"
Expand Down Expand Up @@ -1230,6 +1232,7 @@ export interface ChatComposerHandle {
) => boolean;
openModelPicker: () => void;
toggleModelPicker: () => void;
openControl: (command: KeybindingCommand) => void;
isModelPickerOpen: () => boolean;
compactContext: () => void;
readSnapshot: () => {
Expand Down Expand Up @@ -5730,6 +5733,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
openModelPicker();
}
},
openControl: (command) => {
if (composerBlurFrameRef.current !== null) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
composerBlurFrameRef.current = null;
}
flushSync(() => {
setIsComposerScrollCollapsed(false);
setIsComposerFocused(true);
});
const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]');
const trigger = Array.from(
shell?.querySelectorAll<HTMLButtonElement>(
`button[data-composer-shortcut~="${command}"]:not(:disabled)`,
) ?? [],
).find(
(element) =>
!element.closest("[inert]") && element.checkVisibility({ visibilityProperty: true }),
);
if (!trigger) return;
trigger.focus({ preventScroll: true });
trigger.click();
},
compactContext: compactThreadContext,
isModelPickerOpen: () => isComposerModelPickerOpen,
readSnapshot: () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
variant="ghost"
className={size === "xs" ? "shrink-0" : "shrink-0 px-2"}
aria-label="More composer controls"
data-composer-shortcut={
props.traitsMenuContent ? "composer.mode composer.effort" : "composer.mode"
}
/>
}
>
Expand Down
Loading
Loading