mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d4b94acda | ||
|
|
954046f671 | ||
|
|
ff89c85255 | ||
|
|
1d8a138d0d | ||
|
|
a5c6acb73b | ||
|
|
00a2eec5f6 | ||
|
|
859d234543 | ||
|
|
207bdd1fae | ||
|
|
d102a8fd64 | ||
|
|
fcbdb09796 | ||
|
|
59f3f4737a | ||
|
|
c80c3e0256 | ||
|
|
ad1cb4c200 | ||
|
|
68ab7b81a1 |
@@ -35,41 +35,7 @@ env:
|
||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||
|
||||
jobs:
|
||||
cleanup-assets:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Delete all existing release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)
|
||||
if [[ -z "$assets" ]]; then
|
||||
echo "No existing assets to clean up"
|
||||
exit 0
|
||||
fi
|
||||
for asset in $assets; do
|
||||
echo "Deleting $asset"
|
||||
gh release delete-asset "$RELEASE_TAG" "$asset" --repo "${{ github.repository }}" --yes || true
|
||||
done
|
||||
|
||||
publish-macos:
|
||||
needs: cleanup-assets
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -183,7 +149,6 @@ jobs:
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
|
||||
publish-linux:
|
||||
needs: cleanup-assets
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -284,7 +249,6 @@ jobs:
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
|
||||
publish-windows:
|
||||
needs: cleanup-assets
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
|
||||
import {
|
||||
@@ -118,6 +119,7 @@ export function AgentInputArea({
|
||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||
const isDictationReady =
|
||||
isConnected &&
|
||||
(agentDirectoryStatus === "ready" ||
|
||||
@@ -623,7 +625,7 @@ export function AgentInputArea({
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||
<Shortcut keys={["mod", "shift", "D"]} style={styles.tooltipShortcut} />
|
||||
{voiceToggleKeys && <Shortcut keys={voiceToggleKeys} style={styles.tooltipShortcut} />}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* Compute the pixel width for a line-number gutter based on the highest
|
||||
* line number that will be displayed. Minimum width accommodates 2 digits.
|
||||
*/
|
||||
export function lineNumberGutterWidth(maxLineNumber: number): number {
|
||||
const digits = Math.max(2, String(maxLineNumber).length);
|
||||
return digits * 8 + 12;
|
||||
}
|
||||
|
||||
export function getCodeInsets(theme: any) {
|
||||
const padding =
|
||||
typeof theme.spacing?.[3] === "number"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, useCallback, useEffect, useId, useRef } from "react";
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
scrollViewWidth: number;
|
||||
onScrollViewWidthChange: (width: number) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function DiffScroll({
|
||||
children,
|
||||
scrollViewWidth,
|
||||
onScrollViewWidthChange,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
}: DiffScrollProps) {
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const scrollId = useId();
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
let closeGestureRef: React.MutableRefObject<any> | undefined;
|
||||
try {
|
||||
const animation = useExplorerSidebarAnimation();
|
||||
closeGestureRef = animation.closeGestureRef;
|
||||
} catch {
|
||||
// Not inside ExplorerSidebarAnimationProvider, which is fine
|
||||
}
|
||||
|
||||
// Register/unregister scroll offset tracking
|
||||
useEffect(() => {
|
||||
if (!horizontalScroll) return;
|
||||
// Start at 0 (not scrolled)
|
||||
horizontalScroll.registerScrollOffset(scrollId, 0);
|
||||
return () => {
|
||||
horizontalScroll.unregisterScrollOffset(scrollId);
|
||||
};
|
||||
}, [horizontalScroll, scrollId]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
// Track if we're at the left edge (with small threshold for float precision)
|
||||
setIsAtLeftEdge(offsetX <= 1);
|
||||
if (horizontalScroll) {
|
||||
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
||||
}
|
||||
},
|
||||
[horizontalScroll, scrollId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
bounces={false}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
||||
// When at left edge, wait for close gesture to fail before scrolling.
|
||||
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
|
||||
// so scrolling left works normally. On rightward swipes, close gesture
|
||||
// activates and closes the sidebar.
|
||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
scrollViewWidth: number;
|
||||
onScrollViewWidthChange: (width: number) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function DiffScroll({
|
||||
children,
|
||||
onScrollViewWidthChange,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
}: DiffScrollProps) {
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type HighlightToken,
|
||||
type HighlightStyle,
|
||||
} from "@getpaseo/highlight";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -100,6 +101,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
opacity: 0.4,
|
||||
userSelect: "none",
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
@@ -135,8 +137,7 @@ function FilePreviewBody({
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!highlightedLines) return 0;
|
||||
const digits = String(highlightedLines.length).length;
|
||||
return Math.max(digits * 8 + 12, 28);
|
||||
return lineNumberGutterWidth(highlightedLines.length);
|
||||
}, [highlightedLines]);
|
||||
|
||||
const handlePreviewScroll = useCallback(
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
memo,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
@@ -44,8 +42,7 @@ import {
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { DiffScroll } from "./diff-scroll";
|
||||
import {
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
@@ -64,6 +61,7 @@ import {
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
@@ -261,41 +259,6 @@ function DiffFileBody({
|
||||
testID?: string;
|
||||
}) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const scrollId = useId();
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
let closeGestureRef: React.MutableRefObject<any> | undefined;
|
||||
try {
|
||||
const animation = useExplorerSidebarAnimation();
|
||||
closeGestureRef = animation.closeGestureRef;
|
||||
} catch {
|
||||
// Not inside ExplorerSidebarAnimationProvider, which is fine
|
||||
}
|
||||
|
||||
// Register/unregister scroll offset tracking
|
||||
useEffect(() => {
|
||||
if (!horizontalScroll) return;
|
||||
// Start at 0 (not scrolled)
|
||||
horizontalScroll.registerScrollOffset(scrollId, 0);
|
||||
return () => {
|
||||
horizontalScroll.unregisterScrollOffset(scrollId);
|
||||
};
|
||||
}, [horizontalScroll, scrollId]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
// Track if we're at the left edge (with small threshold for float precision)
|
||||
setIsAtLeftEdge(offsetX <= 1);
|
||||
if (horizontalScroll) {
|
||||
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
||||
}
|
||||
},
|
||||
[horizontalScroll, scrollId],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -325,8 +288,7 @@ function DiffFileBody({
|
||||
hunk.newStart + hunk.newCount,
|
||||
);
|
||||
}
|
||||
const digitCount = Math.max(1, String(maxLineNo).length);
|
||||
const gutterWidth = digitCount * 8 + 12;
|
||||
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||
return file.hunks.map((hunk, hunkIndex) => {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
@@ -364,29 +326,18 @@ function DiffFileBody({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
bounces={false}
|
||||
<DiffScroll
|
||||
scrollViewWidth={scrollViewWidth}
|
||||
onScrollViewWidthChange={setScrollViewWidth}
|
||||
style={styles.diffContent}
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||
// When at left edge, wait for close gesture to fail before scrolling.
|
||||
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
|
||||
// so scrolling left works normally. On rightward swipes, close gesture
|
||||
// activates and closes the sidebar.
|
||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||
>
|
||||
<View
|
||||
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
|
||||
>
|
||||
{linesContent}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</DiffScroll>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
@@ -1427,6 +1378,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
userSelect: "none",
|
||||
},
|
||||
addLineNumberText: {
|
||||
color: theme.colors.palette.green[400],
|
||||
@@ -1441,6 +1393,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foreground,
|
||||
userSelect: "text",
|
||||
},
|
||||
addLineContainer: {
|
||||
backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { View, Pressable, Text, Platform } from "react-native";
|
||||
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -24,8 +24,9 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
@@ -47,7 +48,8 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
@@ -353,6 +355,7 @@ function MobileSidebar({
|
||||
closeToAgent,
|
||||
handleViewMoreNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -562,7 +565,7 @@ function MobileSidebar({
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={["⌘", "⇧", "O"]} />
|
||||
{newAgentKeys && <Shortcut keys={newAgentKeys} />}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -626,19 +629,58 @@ function DesktopSidebar({
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const hostStatusDotStyle = useMemo(
|
||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||
[activeHostStatusColor],
|
||||
);
|
||||
|
||||
const startWidthRef = useRef(sidebarWidth);
|
||||
const resizeWidth = useSharedValue(sidebarWidth);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = sidebarWidth;
|
||||
}, [sidebarWidth, resizeWidth]);
|
||||
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = sidebarWidth;
|
||||
resizeWidth.value = sidebarWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Dragging right (positive translationX) increases width
|
||||
const newWidth = startWidthRef.current + event.translationX;
|
||||
const maxWidth = Math.max(
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||
resizeWidth.value = clampedWidth;
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setSidebarWidth)(resizeWidth.value);
|
||||
}),
|
||||
[sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||
{trafficLightPadding.top > 0 ? (
|
||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||
) : null}
|
||||
@@ -737,7 +779,14 @@ function DesktopSidebar({
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Resize handle - absolutely positioned over right border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -763,10 +812,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
resizeHandle: {
|
||||
position: "absolute",
|
||||
right: -5,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarHeader: {
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
|
||||
@@ -49,6 +49,11 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import {
|
||||
shouldAutoReplayQueuedAgentMessage,
|
||||
takeQueuedAgentMessageReplay,
|
||||
type QueuedAgentReplaySource,
|
||||
} from "@/contexts/session-queued-message-replay";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -272,7 +277,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||
const sendAgentMessageRef = useRef<
|
||||
((agentId: string, message: string, images?: AttachmentMetadata[]) => Promise<void>) | null
|
||||
((
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: AttachmentMetadata[],
|
||||
messageId?: string,
|
||||
) => Promise<void>) | null
|
||||
>(null);
|
||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -349,7 +359,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
const applyAuthoritativeAgentSnapshot = useCallback(
|
||||
(agent: Agent) => {
|
||||
(agent: Agent, options?: { source?: QueuedAgentReplaySource }) => {
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
@@ -423,17 +433,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
});
|
||||
|
||||
const prevStatus = previousAgentStatusRef.current.get(agent.id);
|
||||
if (prevStatus === "running" && agent.status !== "running") {
|
||||
if (
|
||||
shouldAutoReplayQueuedAgentMessage({
|
||||
previousStatus: prevStatus,
|
||||
nextStatus: agent.status,
|
||||
source: options?.source ?? "live",
|
||||
})
|
||||
) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const queue = session?.queuedMessages.get(agent.id);
|
||||
if (queue && queue.length > 0) {
|
||||
const [next, ...rest] = queue;
|
||||
const replay = takeQueuedAgentMessageReplay(session?.queuedMessages.get(agent.id));
|
||||
if (replay) {
|
||||
if (sendAgentMessageRef.current) {
|
||||
void sendAgentMessageRef.current(agent.id, next.text, next.images);
|
||||
void sendAgentMessageRef.current(
|
||||
agent.id,
|
||||
replay.text,
|
||||
replay.images,
|
||||
replay.messageId,
|
||||
);
|
||||
}
|
||||
setQueuedMessages(serverId, (prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(agent.id, rest);
|
||||
updated.set(agent.id, replay.remainingQueue);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -722,7 +742,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}),
|
||||
};
|
||||
|
||||
applyAuthoritativeAgentSnapshot(agent);
|
||||
applyAuthoritativeAgentSnapshot(agent, { source: "live" });
|
||||
},
|
||||
[
|
||||
applyAuthoritativeAgentSnapshot,
|
||||
@@ -772,10 +792,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
if (payload.agent) {
|
||||
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
|
||||
applyAuthoritativeAgentSnapshot({
|
||||
...normalized,
|
||||
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
|
||||
});
|
||||
applyAuthoritativeAgentSnapshot(
|
||||
{
|
||||
...normalized,
|
||||
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
|
||||
},
|
||||
{ source: "hydrate" },
|
||||
);
|
||||
}
|
||||
|
||||
// Call pure reducer
|
||||
@@ -1469,11 +1492,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
]);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
async (agentId: string, message: string, images?: AttachmentMetadata[]) => {
|
||||
const messageId = generateMessageId();
|
||||
async (
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: AttachmentMetadata[],
|
||||
messageId?: string,
|
||||
) => {
|
||||
const resolvedMessageId = messageId ?? generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: messageId,
|
||||
id: resolvedMessageId,
|
||||
text: message,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
@@ -1507,7 +1535,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
void client
|
||||
.sendAgentMessage(agentId, message, {
|
||||
messageId,
|
||||
messageId: resolvedMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
shouldAutoReplayQueuedAgentMessage,
|
||||
takeQueuedAgentMessageReplay,
|
||||
} from "./session-queued-message-replay";
|
||||
|
||||
describe("takeQueuedAgentMessageReplay", () => {
|
||||
it("preserves the queued message id for idempotent replay", () => {
|
||||
const replay = takeQueuedAgentMessageReplay([
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "resume this",
|
||||
images: [
|
||||
{
|
||||
id: "img-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachment:img-1",
|
||||
fileName: "test.png",
|
||||
byteSize: 12,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
text: "second",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(replay).toEqual({
|
||||
messageId: "msg-1",
|
||||
text: "resume this",
|
||||
images: [
|
||||
{
|
||||
id: "img-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachment:img-1",
|
||||
fileName: "test.png",
|
||||
byteSize: 12,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
remainingQueue: [{ id: "msg-2", text: "second" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the queue is empty", () => {
|
||||
expect(takeQueuedAgentMessageReplay([])).toBeNull();
|
||||
expect(takeQueuedAgentMessageReplay(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("only auto-replays queued messages for live running-to-idle transitions", () => {
|
||||
expect(
|
||||
shouldAutoReplayQueuedAgentMessage({
|
||||
previousStatus: "running",
|
||||
nextStatus: "idle",
|
||||
source: "live",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldAutoReplayQueuedAgentMessage({
|
||||
previousStatus: "running",
|
||||
nextStatus: "idle",
|
||||
source: "hydrate",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldAutoReplayQueuedAgentMessage({
|
||||
previousStatus: "idle",
|
||||
nextStatus: "idle",
|
||||
source: "live",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
|
||||
export interface QueuedAgentMessageReplayItem {
|
||||
id: string;
|
||||
text: string;
|
||||
images?: AttachmentMetadata[];
|
||||
}
|
||||
|
||||
export interface QueuedAgentMessageReplay {
|
||||
messageId: string;
|
||||
text: string;
|
||||
images?: AttachmentMetadata[];
|
||||
remainingQueue: QueuedAgentMessageReplayItem[];
|
||||
}
|
||||
|
||||
export type QueuedAgentReplaySource = "hydrate" | "live";
|
||||
|
||||
export function shouldAutoReplayQueuedAgentMessage(input: {
|
||||
previousStatus: string | undefined;
|
||||
nextStatus: string;
|
||||
source: QueuedAgentReplaySource;
|
||||
}): boolean {
|
||||
return input.source === "live" && input.previousStatus === "running" && input.nextStatus !== "running";
|
||||
}
|
||||
|
||||
export function takeQueuedAgentMessageReplay(
|
||||
queue: readonly QueuedAgentMessageReplayItem[] | undefined,
|
||||
): QueuedAgentMessageReplay | null {
|
||||
if (!queue || queue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [next, ...remainingQueue] = queue;
|
||||
return {
|
||||
messageId: next.id,
|
||||
text: next.text,
|
||||
images: next.images,
|
||||
remainingQueue,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
|
||||
@@ -48,7 +53,7 @@ type CommandCenterActionDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
actionId?: string;
|
||||
keywords: string[];
|
||||
routeKind: "settings" | "none";
|
||||
};
|
||||
@@ -58,7 +63,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
id: "new-agent",
|
||||
title: "Open project",
|
||||
icon: "plus",
|
||||
shortcutKeys: ["mod", "shift", "O"],
|
||||
actionId: "new-agent",
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
routeKind: "none",
|
||||
},
|
||||
@@ -99,9 +104,25 @@ export type CommandCenterItem =
|
||||
agent: AggregatedAgent;
|
||||
};
|
||||
|
||||
function resolveActionShortcutKeys(
|
||||
actionId: string | undefined,
|
||||
overrides: Record<string, string>,
|
||||
): ShortcutKey[] | undefined {
|
||||
if (!actionId) return undefined;
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
const platform = { isMac, isDesktop };
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return undefined;
|
||||
const override = overrides[bindingId];
|
||||
if (override) return comboStringToShortcutKeys(override);
|
||||
return getDefaultKeysForAction(actionId, platform) ?? undefined;
|
||||
}
|
||||
|
||||
export function useCommandCenter() {
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
@@ -158,9 +179,9 @@ export function useCommandCenter() {
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.routeKind === "settings" ? settingsRoute : undefined,
|
||||
shortcutKeys: action.shortcutKeys,
|
||||
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
||||
}));
|
||||
}, [open, query, settingsRoute]);
|
||||
}, [open, query, settingsRoute, overrides]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
|
||||
const QUERY_KEY = ["keyboard-shortcut-overrides"];
|
||||
|
||||
const EMPTY_OVERRIDES: Record<string, string> = {};
|
||||
|
||||
export interface UseKeyboardShortcutOverridesReturn {
|
||||
overrides: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
setOverride: (bindingId: string, comboString: string) => Promise<void>;
|
||||
removeOverride: (bindingId: string) => Promise<void>;
|
||||
resetAll: () => Promise<void>;
|
||||
hasOverrides: boolean;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcutOverrides(): UseKeyboardShortcutOverridesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: loadOverridesFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const setOverride = useCallback(
|
||||
async (bindingId: string, comboString: string) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
|
||||
const next = { ...prev, [bindingId]: comboString };
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const removeOverride = useCallback(
|
||||
async (bindingId: string) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
|
||||
const { [bindingId]: _, ...next } = prev;
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const resetAll = useCallback(async () => {
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, EMPTY_OVERRIDES);
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
}, [queryClient]);
|
||||
|
||||
const overrides = data ?? EMPTY_OVERRIDES;
|
||||
|
||||
return {
|
||||
overrides,
|
||||
isLoading: isPending,
|
||||
setOverride,
|
||||
removeOverride,
|
||||
resetAll,
|
||||
hasOverrides: Object.keys(overrides).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOverridesFromStorage(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as Record<string, string>;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err);
|
||||
}
|
||||
return EMPTY_OVERRIDES;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
} from "@/keyboard/actions";
|
||||
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
@@ -38,6 +39,8 @@ export function useKeyboardShortcuts({
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
|
||||
const activeServerId =
|
||||
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
|
||||
@@ -285,6 +288,7 @@ export function useKeyboardShortcuts({
|
||||
toggleFileExplorer,
|
||||
}),
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
if (!match) {
|
||||
return;
|
||||
@@ -332,6 +336,7 @@ export function useKeyboardShortcuts({
|
||||
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
||||
};
|
||||
}, [
|
||||
bindings,
|
||||
enabled,
|
||||
isMobile,
|
||||
openProjectPickerAction,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
|
||||
export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
|
||||
return useMemo(() => {
|
||||
const platform = { isMac, isDesktop };
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return null;
|
||||
|
||||
const override = overrides[bindingId];
|
||||
if (override) {
|
||||
return comboStringToShortcutKeys(override);
|
||||
}
|
||||
|
||||
return getDefaultKeysForAction(actionId, platform);
|
||||
}, [actionId, overrides, isMac, isDesktop]);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import type {
|
||||
KeyboardShortcutPayload,
|
||||
MessageInputKeyboardActionKind,
|
||||
} from "@/keyboard/actions";
|
||||
import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string";
|
||||
|
||||
export type { KeyCombo } from "@/keyboard/shortcut-string";
|
||||
|
||||
// --- Public types ---
|
||||
|
||||
@@ -43,18 +46,6 @@ type KeyboardShortcutPlatformContext = {
|
||||
isDesktop: boolean;
|
||||
};
|
||||
|
||||
interface KeyCombo {
|
||||
/** Event.code to match. Use "Digit" for any digit 1-9. */
|
||||
code: string;
|
||||
/** Optional event.key fallback (case-insensitive). */
|
||||
key?: string;
|
||||
meta?: true;
|
||||
ctrl?: true;
|
||||
alt?: true;
|
||||
shift?: true;
|
||||
/** Set to false to block key repeat events. */
|
||||
repeat?: false;
|
||||
}
|
||||
|
||||
interface ShortcutWhen {
|
||||
/** true = mac only, false = non-mac only */
|
||||
@@ -87,7 +78,8 @@ interface ShortcutHelp {
|
||||
interface ShortcutBinding {
|
||||
id: string;
|
||||
action: KeyboardActionId;
|
||||
combo: KeyCombo;
|
||||
combo: string;
|
||||
repeat?: false;
|
||||
when?: ShortcutWhen;
|
||||
payload?: ShortcutPayloadDef;
|
||||
preventDefault?: boolean;
|
||||
@@ -95,6 +87,10 @@ interface ShortcutBinding {
|
||||
help?: ShortcutHelp;
|
||||
}
|
||||
|
||||
export interface ParsedShortcutBinding extends ShortcutBinding {
|
||||
parsedCombo: KeyCombo;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const SHORTCUT_HELP_SECTION_TITLES: Record<KeyboardShortcutHelpSection["id"], string> = {
|
||||
@@ -109,7 +105,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "agent-new-cmd-shift-o-mac",
|
||||
action: "agent.new",
|
||||
combo: { code: "KeyO", key: "o", meta: true, shift: true },
|
||||
combo: "Cmd+Shift+O",
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "new-agent",
|
||||
@@ -121,7 +117,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "agent-new-ctrl-shift-o-non-mac",
|
||||
action: "agent.new",
|
||||
combo: { code: "KeyO", key: "o", ctrl: true, shift: true },
|
||||
combo: "Ctrl+Shift+O",
|
||||
when: { mac: false, terminal: false },
|
||||
help: {
|
||||
id: "new-agent",
|
||||
@@ -135,7 +131,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-new-cmd-t-mac",
|
||||
action: "workspace.tab.new",
|
||||
combo: { code: "KeyT", key: "t", meta: true },
|
||||
combo: "Cmd+T",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
@@ -147,7 +143,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-new-ctrl-t-non-mac",
|
||||
action: "workspace.tab.new",
|
||||
combo: { code: "KeyT", key: "t", ctrl: true },
|
||||
combo: "Ctrl+T",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
@@ -159,7 +155,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-close-current-cmd-w-mac",
|
||||
action: "workspace.tab.close.current",
|
||||
combo: { code: "KeyW", key: "w", meta: true },
|
||||
combo: "Cmd+W",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
@@ -171,7 +167,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-close-current-ctrl-w-non-mac",
|
||||
action: "workspace.tab.close.current",
|
||||
combo: { code: "KeyW", key: "w", ctrl: true },
|
||||
combo: "Ctrl+W",
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
@@ -183,7 +179,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-close-current-alt-shift-w-web",
|
||||
action: "workspace.tab.close.current",
|
||||
combo: { code: "KeyW", key: "w", alt: true, shift: true },
|
||||
combo: "Alt+Shift+W",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
@@ -197,7 +193,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-index-cmd-digit-mac",
|
||||
action: "workspace.navigate.index",
|
||||
combo: { code: "Digit", meta: true },
|
||||
combo: "Cmd+Digit",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
@@ -210,7 +206,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-index-ctrl-digit-non-mac",
|
||||
action: "workspace.navigate.index",
|
||||
combo: { code: "Digit", ctrl: true },
|
||||
combo: "Ctrl+Digit",
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
@@ -223,7 +219,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-index-alt-digit-web",
|
||||
action: "workspace.navigate.index",
|
||||
combo: { code: "Digit", alt: true },
|
||||
combo: "Alt+Digit",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
@@ -238,7 +234,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-navigate-index-alt-digit-desktop",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: { code: "Digit", alt: true },
|
||||
combo: "Alt+Digit",
|
||||
when: { desktop: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
@@ -251,7 +247,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-navigate-index-alt-shift-digit-web",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: { code: "Digit", alt: true, shift: true },
|
||||
combo: "Alt+Shift+Digit",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
@@ -266,7 +262,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-relative-cmd-left-mac",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: { code: "BracketLeft", key: "[", meta: true },
|
||||
combo: "Cmd+[",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
@@ -279,7 +275,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-relative-ctrl-left-non-mac",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: { code: "BracketLeft", key: "[", ctrl: true },
|
||||
combo: "Ctrl+[",
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
@@ -292,7 +288,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-relative-cmd-right-mac",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: { code: "BracketRight", key: "]", meta: true },
|
||||
combo: "Cmd+]",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
@@ -305,7 +301,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-relative-ctrl-right-non-mac",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: { code: "BracketRight", key: "]", ctrl: true },
|
||||
combo: "Ctrl+]",
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
@@ -318,7 +314,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-relative-alt-left-web",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: { code: "BracketLeft", key: "[", alt: true },
|
||||
combo: "Alt+[",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
@@ -331,7 +327,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-navigate-relative-alt-right-web",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: { code: "BracketRight", key: "]", alt: true },
|
||||
combo: "Alt+]",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
@@ -346,7 +342,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-left",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: { code: "BracketLeft", alt: true, shift: true },
|
||||
combo: "Alt+Shift+[",
|
||||
when: { commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
@@ -359,7 +355,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-right",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: { code: "BracketRight", alt: true, shift: true },
|
||||
combo: "Alt+Shift+]",
|
||||
when: { commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
@@ -374,7 +370,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-split-right-cmd-backslash",
|
||||
action: "workspace.pane.split.right",
|
||||
combo: { code: "Backslash", meta: true },
|
||||
combo: "Cmd+\\",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-split-right",
|
||||
@@ -386,7 +382,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-split-down-cmd-shift-backslash",
|
||||
action: "workspace.pane.split.down",
|
||||
combo: { code: "Backslash", meta: true, shift: true },
|
||||
combo: "Cmd+Shift+\\",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-split-down",
|
||||
@@ -398,7 +394,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-focus-left-cmd-shift-left",
|
||||
action: "workspace.pane.focus.left",
|
||||
combo: { code: "ArrowLeft", meta: true, shift: true },
|
||||
combo: "Cmd+Shift+ArrowLeft",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-left",
|
||||
@@ -410,7 +406,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-focus-right-cmd-shift-right",
|
||||
action: "workspace.pane.focus.right",
|
||||
combo: { code: "ArrowRight", meta: true, shift: true },
|
||||
combo: "Cmd+Shift+ArrowRight",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-right",
|
||||
@@ -422,7 +418,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-focus-up-cmd-shift-up",
|
||||
action: "workspace.pane.focus.up",
|
||||
combo: { code: "ArrowUp", meta: true, shift: true },
|
||||
combo: "Cmd+Shift+ArrowUp",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-up",
|
||||
@@ -434,7 +430,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-focus-down-cmd-shift-down",
|
||||
action: "workspace.pane.focus.down",
|
||||
combo: { code: "ArrowDown", meta: true, shift: true },
|
||||
combo: "Cmd+Shift+ArrowDown",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-down",
|
||||
@@ -446,7 +442,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-move-tab-left-cmd-shift-alt-left",
|
||||
action: "workspace.pane.move-tab.left",
|
||||
combo: { code: "ArrowLeft", meta: true, alt: true, shift: true },
|
||||
combo: "Cmd+Alt+Shift+ArrowLeft",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-left",
|
||||
@@ -458,7 +454,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-move-tab-right-cmd-shift-alt-right",
|
||||
action: "workspace.pane.move-tab.right",
|
||||
combo: { code: "ArrowRight", meta: true, alt: true, shift: true },
|
||||
combo: "Cmd+Alt+Shift+ArrowRight",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-right",
|
||||
@@ -470,7 +466,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-move-tab-up-cmd-shift-alt-up",
|
||||
action: "workspace.pane.move-tab.up",
|
||||
combo: { code: "ArrowUp", meta: true, alt: true, shift: true },
|
||||
combo: "Cmd+Alt+Shift+ArrowUp",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-up",
|
||||
@@ -482,7 +478,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-move-tab-down-cmd-shift-alt-down",
|
||||
action: "workspace.pane.move-tab.down",
|
||||
combo: { code: "ArrowDown", meta: true, alt: true, shift: true },
|
||||
combo: "Cmd+Alt+Shift+ArrowDown",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-down",
|
||||
@@ -494,7 +490,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "workspace-pane-close-cmd-shift-w",
|
||||
action: "workspace.pane.close",
|
||||
combo: { code: "KeyW", key: "w", meta: true, shift: true },
|
||||
combo: "Cmd+Shift+W",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-close",
|
||||
@@ -508,7 +504,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "command-center-toggle-cmd-k-mac",
|
||||
action: "command-center.toggle",
|
||||
combo: { code: "KeyK", key: "k", meta: true },
|
||||
combo: "Cmd+K",
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "toggle-command-center",
|
||||
@@ -520,7 +516,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "command-center-toggle-ctrl-k-non-mac",
|
||||
action: "command-center.toggle",
|
||||
combo: { code: "KeyK", key: "k", ctrl: true },
|
||||
combo: "Ctrl+K",
|
||||
when: { mac: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-command-center",
|
||||
@@ -534,7 +530,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "shortcuts-dialog-toggle-question-mark",
|
||||
action: "shortcuts.dialog.toggle",
|
||||
combo: { code: "Slash", key: "?", shift: true, repeat: false },
|
||||
combo: "Shift+?",
|
||||
repeat: false,
|
||||
when: { focusScope: "other" },
|
||||
help: {
|
||||
id: "show-shortcuts",
|
||||
@@ -549,7 +546,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "sidebar-toggle-left-mac-cmd-b",
|
||||
action: "sidebar.toggle.left",
|
||||
combo: { code: "KeyB", key: "b", meta: true },
|
||||
combo: "Cmd+B",
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
@@ -561,13 +558,13 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "sidebar-toggle-left-cmd-period-mac",
|
||||
action: "sidebar.toggle.left",
|
||||
combo: { code: "Period", key: ".", meta: true },
|
||||
combo: "Cmd+.",
|
||||
when: { mac: true, commandCenter: false },
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-left-ctrl-period-non-mac",
|
||||
action: "sidebar.toggle.left",
|
||||
combo: { code: "Period", key: ".", ctrl: true },
|
||||
combo: "Ctrl+.",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
@@ -579,7 +576,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "sidebar-toggle-right-cmd-e-mac",
|
||||
action: "sidebar.toggle.right",
|
||||
combo: { code: "KeyE", key: "e", meta: true },
|
||||
combo: "Cmd+E",
|
||||
when: { mac: true, hasSelectedAgent: true, commandCenter: false },
|
||||
help: {
|
||||
id: "toggle-right-sidebar",
|
||||
@@ -591,7 +588,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "sidebar-toggle-right-ctrl-e-non-mac",
|
||||
action: "sidebar.toggle.right",
|
||||
combo: { code: "KeyE", key: "e", ctrl: true },
|
||||
combo: "Ctrl+E",
|
||||
when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-right-sidebar",
|
||||
@@ -603,7 +600,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "sidebar-toggle-right-ctrl-backquote",
|
||||
action: "sidebar.toggle.right",
|
||||
combo: { code: "Backquote", key: "`", ctrl: true },
|
||||
combo: "Ctrl+`",
|
||||
when: { hasSelectedAgent: true, commandCenter: false },
|
||||
},
|
||||
|
||||
@@ -611,7 +608,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
||||
action: "message-input.action",
|
||||
combo: { code: "KeyD", key: "d", meta: true, shift: true, repeat: false },
|
||||
combo: "Cmd+Shift+D",
|
||||
repeat: false,
|
||||
when: { mac: true, commandCenter: false, terminal: false },
|
||||
payload: { type: "message-input", kind: "voice-toggle" },
|
||||
help: {
|
||||
@@ -624,7 +622,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "message-input-voice-toggle-ctrl-shift-d-non-mac",
|
||||
action: "message-input.action",
|
||||
combo: { code: "KeyD", key: "d", ctrl: true, shift: true, repeat: false },
|
||||
combo: "Ctrl+Shift+D",
|
||||
repeat: false,
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
payload: { type: "message-input", kind: "voice-toggle" },
|
||||
help: {
|
||||
@@ -637,7 +636,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "message-input-dictation-toggle-cmd-d-mac",
|
||||
action: "message-input.action",
|
||||
combo: { code: "KeyD", key: "d", meta: true },
|
||||
combo: "Cmd+D",
|
||||
when: { mac: true, commandCenter: false, terminal: false },
|
||||
payload: { type: "message-input", kind: "dictation-toggle" },
|
||||
help: {
|
||||
@@ -650,7 +649,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "message-input-dictation-toggle-ctrl-d-non-mac",
|
||||
action: "message-input.action",
|
||||
combo: { code: "KeyD", key: "d", ctrl: true },
|
||||
combo: "Ctrl+D",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
payload: { type: "message-input", kind: "dictation-toggle" },
|
||||
help: {
|
||||
@@ -663,7 +662,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "message-input-dictation-cancel",
|
||||
action: "message-input.action",
|
||||
combo: { code: "Escape" },
|
||||
combo: "Escape",
|
||||
when: { commandCenter: false, terminal: false },
|
||||
payload: { type: "message-input", kind: "dictation-cancel" },
|
||||
preventDefault: false,
|
||||
@@ -678,7 +677,8 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
{
|
||||
id: "message-input-voice-mute-toggle",
|
||||
action: "message-input.action",
|
||||
combo: { code: "Space", key: " ", repeat: false },
|
||||
combo: "Space",
|
||||
repeat: false,
|
||||
when: { commandCenter: false, focusScope: "other" },
|
||||
payload: { type: "message-input", kind: "voice-mute-toggle" },
|
||||
help: {
|
||||
@@ -690,6 +690,40 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// --- Parse bindings at module load ---
|
||||
|
||||
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
|
||||
const parsedCombo = parseShortcutString(binding.combo);
|
||||
if (binding.repeat === false) {
|
||||
parsedCombo.repeat = false;
|
||||
}
|
||||
return { ...binding, parsedCombo };
|
||||
}
|
||||
|
||||
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
|
||||
SHORTCUT_BINDINGS.map(parseBinding);
|
||||
|
||||
export function buildEffectiveBindings(
|
||||
overrides: Record<string, string>,
|
||||
): ParsedShortcutBinding[] {
|
||||
return DEFAULT_BINDINGS.map(function (binding) {
|
||||
const override = overrides[binding.id];
|
||||
if (override === undefined) {
|
||||
return binding;
|
||||
}
|
||||
let parsedCombo: KeyCombo;
|
||||
try {
|
||||
parsedCombo = parseShortcutString(override);
|
||||
} catch {
|
||||
return binding;
|
||||
}
|
||||
if (binding.repeat === false) {
|
||||
parsedCombo.repeat = false;
|
||||
}
|
||||
return { ...binding, combo: override, parsedCombo };
|
||||
});
|
||||
}
|
||||
|
||||
// --- Matching engine ---
|
||||
|
||||
function parseDigit(event: KeyboardEvent): number | null {
|
||||
@@ -709,9 +743,19 @@ function parseDigit(event: KeyboardEvent): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesCombo(combo: KeyCombo, event: KeyboardEvent): boolean {
|
||||
if (!!combo.meta !== event.metaKey) return false;
|
||||
if (!!combo.ctrl !== event.ctrlKey) return false;
|
||||
function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): boolean {
|
||||
if (combo.mod) {
|
||||
if (isMac) {
|
||||
if (!event.metaKey) return false;
|
||||
if (!!combo.ctrl !== event.ctrlKey) return false;
|
||||
} else {
|
||||
if (!event.ctrlKey) return false;
|
||||
if (!!combo.meta !== event.metaKey) return false;
|
||||
}
|
||||
} else {
|
||||
if (!!combo.meta !== event.metaKey) return false;
|
||||
if (!!combo.ctrl !== event.ctrlKey) return false;
|
||||
}
|
||||
if (!!combo.alt !== event.altKey) return false;
|
||||
if (!!combo.shift !== event.shiftKey) return false;
|
||||
if (combo.repeat === false && event.repeat) return false;
|
||||
@@ -767,10 +811,11 @@ function helpMatchesPlatform(
|
||||
export function resolveKeyboardShortcut(input: {
|
||||
event: KeyboardEvent;
|
||||
context: KeyboardShortcutContext;
|
||||
bindings?: readonly ParsedShortcutBinding[];
|
||||
}): KeyboardShortcutMatch | null {
|
||||
const { event, context } = input;
|
||||
for (const binding of SHORTCUT_BINDINGS) {
|
||||
if (!matchesCombo(binding.combo, event)) {
|
||||
const { event, context, bindings = DEFAULT_BINDINGS } = input;
|
||||
for (const binding of bindings) {
|
||||
if (!matchesCombo(binding.parsedCombo, event, context.isMac)) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesWhen(binding.when, context)) {
|
||||
@@ -786,8 +831,41 @@ export function resolveKeyboardShortcut(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getBindingIdForAction(
|
||||
actionId: string,
|
||||
platform: { isMac: boolean; isDesktop: boolean },
|
||||
): string | null {
|
||||
for (const binding of DEFAULT_BINDINGS) {
|
||||
if (binding.help?.id !== actionId) {
|
||||
continue;
|
||||
}
|
||||
if (!helpMatchesPlatform(binding.when, platform)) {
|
||||
continue;
|
||||
}
|
||||
return binding.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultKeysForAction(
|
||||
actionId: string,
|
||||
platform: { isMac: boolean; isDesktop: boolean },
|
||||
): ShortcutKey[] | null {
|
||||
for (const binding of DEFAULT_BINDINGS) {
|
||||
if (binding.help?.id !== actionId) {
|
||||
continue;
|
||||
}
|
||||
if (!helpMatchesPlatform(binding.when, platform)) {
|
||||
continue;
|
||||
}
|
||||
return binding.help.keys;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildKeyboardShortcutHelpSections(
|
||||
input: KeyboardShortcutPlatformContext,
|
||||
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
||||
): KeyboardShortcutHelpSection[] {
|
||||
const seenRows = new Set<string>();
|
||||
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
|
||||
@@ -795,7 +873,7 @@ export function buildKeyboardShortcutHelpSections(
|
||||
["agent-input", []],
|
||||
]);
|
||||
|
||||
for (const binding of SHORTCUT_BINDINGS) {
|
||||
for (const binding of bindings) {
|
||||
const help = binding.help;
|
||||
if (!help) {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
export interface KeyCombo {
|
||||
code: string;
|
||||
key?: string;
|
||||
meta?: true;
|
||||
ctrl?: true;
|
||||
alt?: true;
|
||||
shift?: true;
|
||||
mod?: true;
|
||||
repeat?: false;
|
||||
}
|
||||
|
||||
interface KeyMapping {
|
||||
code: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
const KEY_MAP: Record<string, KeyMapping> = {};
|
||||
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
KEY_MAP[letter] = { code: `Key${letter}`, key: letter.toLowerCase() };
|
||||
}
|
||||
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
KEY_MAP[String(i)] = { code: `Digit${i}`, key: String(i) };
|
||||
}
|
||||
|
||||
KEY_MAP["Digit"] = { code: "Digit" };
|
||||
KEY_MAP["\\"] = { code: "Backslash" };
|
||||
KEY_MAP["["] = { code: "BracketLeft", key: "[" };
|
||||
KEY_MAP["]"] = { code: "BracketRight", key: "]" };
|
||||
KEY_MAP["."] = { code: "Period", key: "." };
|
||||
KEY_MAP["`"] = { code: "Backquote", key: "`" };
|
||||
KEY_MAP["/"] = { code: "Slash" };
|
||||
KEY_MAP["?"] = { code: "Slash", key: "?" };
|
||||
KEY_MAP["Space"] = { code: "Space", key: " " };
|
||||
KEY_MAP["Escape"] = { code: "Escape" };
|
||||
KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
|
||||
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
|
||||
KEY_MAP["ArrowUp"] = { code: "ArrowUp" };
|
||||
KEY_MAP["ArrowDown"] = { code: "ArrowDown" };
|
||||
|
||||
const CODE_TO_KEY: Record<string, string> = {};
|
||||
for (const [humanKey, mapping] of Object.entries(KEY_MAP)) {
|
||||
if (!CODE_TO_KEY[mapping.code]) {
|
||||
CODE_TO_KEY[mapping.code] = humanKey;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseShortcutString(s: string): KeyCombo {
|
||||
const parts = s.split("+");
|
||||
if (parts.length === 0 || parts.some((p) => p === "")) {
|
||||
throw new Error(`Invalid shortcut string: "${s}"`);
|
||||
}
|
||||
|
||||
const combo: KeyCombo = { code: "" };
|
||||
let keyPart: string | null = null;
|
||||
|
||||
for (const part of parts) {
|
||||
switch (part) {
|
||||
case "Cmd":
|
||||
combo.meta = true;
|
||||
break;
|
||||
case "Ctrl":
|
||||
combo.ctrl = true;
|
||||
break;
|
||||
case "Alt":
|
||||
combo.alt = true;
|
||||
break;
|
||||
case "Shift":
|
||||
combo.shift = true;
|
||||
break;
|
||||
case "Mod":
|
||||
combo.mod = true;
|
||||
break;
|
||||
default:
|
||||
if (keyPart !== null) {
|
||||
throw new Error(`Invalid shortcut string: "${s}" - multiple key parts`);
|
||||
}
|
||||
keyPart = part;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyPart === null) {
|
||||
throw new Error(`Invalid shortcut string: "${s}" - no key part`);
|
||||
}
|
||||
|
||||
const mapping = KEY_MAP[keyPart];
|
||||
if (!mapping) {
|
||||
throw new Error(`Unknown key: "${keyPart}"`);
|
||||
}
|
||||
|
||||
combo.code = mapping.code;
|
||||
if (mapping.key !== undefined) {
|
||||
combo.key = mapping.key;
|
||||
}
|
||||
|
||||
return combo;
|
||||
}
|
||||
|
||||
export function keyComboToString(combo: KeyCombo): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (combo.mod) {
|
||||
parts.push("Mod");
|
||||
}
|
||||
if (combo.ctrl) parts.push("Ctrl");
|
||||
if (combo.alt) parts.push("Alt");
|
||||
if (combo.shift) parts.push("Shift");
|
||||
if (combo.meta) parts.push("Cmd");
|
||||
|
||||
const humanKey = CODE_TO_KEY[combo.code];
|
||||
if (humanKey) {
|
||||
parts.push(humanKey);
|
||||
}
|
||||
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
"MetaLeft",
|
||||
"MetaRight",
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
"AltLeft",
|
||||
"AltRight",
|
||||
"ShiftLeft",
|
||||
"ShiftRight",
|
||||
]);
|
||||
|
||||
export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
|
||||
const parts = comboString.split("+");
|
||||
const keys: ShortcutKey[] = [];
|
||||
for (const part of parts) {
|
||||
switch (part) {
|
||||
case "Cmd":
|
||||
keys.push("mod");
|
||||
break;
|
||||
case "Ctrl":
|
||||
keys.push("ctrl");
|
||||
break;
|
||||
case "Alt":
|
||||
keys.push("alt");
|
||||
break;
|
||||
case "Shift":
|
||||
keys.push("shift");
|
||||
break;
|
||||
default:
|
||||
keys.push(part.toUpperCase());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (event.ctrlKey) parts.push("Ctrl");
|
||||
if (event.altKey) parts.push("Alt");
|
||||
if (event.shiftKey) parts.push("Shift");
|
||||
if (event.metaKey) parts.push("Cmd");
|
||||
|
||||
const humanKey = CODE_TO_KEY[event.code];
|
||||
if (humanKey) {
|
||||
parts.push(humanKey);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts.join("+");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, Platform } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
getBindingIdForAction,
|
||||
type KeyboardShortcutHelpRow,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
|
||||
function ShortcutRow({
|
||||
row,
|
||||
bindingId,
|
||||
overrideCombo,
|
||||
isCapturing,
|
||||
onRebind,
|
||||
onCancel,
|
||||
onReset,
|
||||
}: {
|
||||
row: KeyboardShortcutHelpRow;
|
||||
bindingId: string | null;
|
||||
overrideCombo: string | undefined;
|
||||
isCapturing: boolean;
|
||||
onRebind: () => void;
|
||||
onCancel: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys;
|
||||
|
||||
return (
|
||||
<View style={[styles.row, isCapturing && styles.rowCapturing]}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
<View style={styles.rowActions}>
|
||||
{isCapturing ? (
|
||||
<Text style={styles.capturingText}>Press shortcut...</Text>
|
||||
) : (
|
||||
<Shortcut keys={displayKeys} />
|
||||
)}
|
||||
{bindingId !== null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={isCapturing ? onCancel : onRebind}
|
||||
>
|
||||
{isCapturing ? "Cancel" : "Rebind"}
|
||||
</Button>
|
||||
)}
|
||||
{overrideCombo !== undefined && !isCapturing && (
|
||||
<Button variant="ghost" size="sm" onPress={onReset}>
|
||||
<Text style={styles.resetText}>Reset</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsSection() {
|
||||
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
|
||||
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
|
||||
useKeyboardShortcutOverrides();
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (capturingBindingId === null) return;
|
||||
|
||||
const activeBindingId = capturingBindingId;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCapturingBindingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") {
|
||||
return;
|
||||
}
|
||||
|
||||
const comboString = keyboardEventToComboString(event);
|
||||
if (comboString === null) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void setOverride(activeBindingId, comboString);
|
||||
setCapturingBindingId(null);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [capturingBindingId, setOverride]);
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
|
||||
<View style={[settingsStyles.card, styles.mobileCard]}>
|
||||
<Text style={styles.mobileText}>
|
||||
Keyboard shortcuts are only available on desktop.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
|
||||
{hasOverrides && (
|
||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||
Reset all
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{sections.map(function (section) {
|
||||
return (
|
||||
<View key={section.id}>
|
||||
<Text style={styles.subsectionTitle}>{section.title}</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
{section.rows.map(function (row, index) {
|
||||
const bindingId = getBindingIdForAction(row.id, { isMac, isDesktop });
|
||||
const overrideCombo = bindingId ? overrides[bindingId] : undefined;
|
||||
|
||||
return (
|
||||
<View key={row.id}>
|
||||
<ShortcutRow
|
||||
row={row}
|
||||
bindingId={bindingId}
|
||||
overrideCombo={overrideCombo}
|
||||
isCapturing={capturingBindingId === bindingId}
|
||||
onRebind={() => setCapturingBindingId(bindingId)}
|
||||
onCancel={() => setCapturingBindingId(null)}
|
||||
onReset={() => {
|
||||
if (bindingId) void removeOverride(bindingId);
|
||||
}}
|
||||
/>
|
||||
{index < section.rows.length - 1 && <View style={styles.separator} />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
subsectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginBottom: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
rowCapturing: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
capturingText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
resetText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
mobileCard: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
mobileText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -42,6 +42,10 @@ export interface ExplorerCheckoutContext {
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 320;
|
||||
export const MIN_SIDEBAR_WIDTH = 200;
|
||||
export const MAX_SIDEBAR_WIDTH = 600;
|
||||
|
||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
|
||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
|
||||
@@ -62,6 +66,7 @@ interface PanelState {
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||
sidebarWidth: number;
|
||||
explorerWidth: number;
|
||||
explorerSortOption: SortOption;
|
||||
explorerFilesSplitRatio: number;
|
||||
@@ -79,6 +84,7 @@ interface PanelState {
|
||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
setExplorerWidth: (width: number) => void;
|
||||
setExplorerSortOption: (option: SortOption) => void;
|
||||
setExplorerFilesSplitRatio: (ratio: number) => void;
|
||||
@@ -91,6 +97,10 @@ function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function clampSidebarWidth(width: number): number {
|
||||
return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
|
||||
}
|
||||
|
||||
function clampWidth(width: number): number {
|
||||
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
|
||||
}
|
||||
@@ -129,6 +139,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
activeExplorerCheckout: null,
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
explorerSortOption: "name",
|
||||
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
@@ -244,6 +255,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}
|
||||
return { activeExplorerCheckout: checkout };
|
||||
}),
|
||||
setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }),
|
||||
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||
setExplorerFilesSplitRatio: (ratio) =>
|
||||
@@ -255,7 +267,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 5,
|
||||
version: 6,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
@@ -310,6 +322,10 @@ export const usePanelStore = create<PanelState>()(
|
||||
state.explorerTabByCheckout = next;
|
||||
}
|
||||
|
||||
if (version < 6 || typeof state.sidebarWidth !== "number") {
|
||||
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
|
||||
state.activeExplorerCheckout = null;
|
||||
|
||||
return state as PanelState;
|
||||
@@ -319,6 +335,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
appId: sh.paseo.desktop
|
||||
productName: Paseo
|
||||
executableName: Paseo
|
||||
afterPack: ./scripts/after-pack.js
|
||||
directories:
|
||||
output: release
|
||||
files:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
const WRAPPER_MODE = 0o755;
|
||||
const WRAPPER_SCRIPT = `#!/bin/bash
|
||||
exec "$(dirname "$(readlink -f "$0")")/${EXECUTABLE_NAME}.bin" --no-sandbox "$@"
|
||||
`;
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName !== "linux") return;
|
||||
|
||||
const chromeSandbox = path.join(context.appOutDir, "chrome-sandbox");
|
||||
if (fs.existsSync(chromeSandbox)) {
|
||||
fs.unlinkSync(chromeSandbox);
|
||||
console.log("Removed chrome-sandbox from Linux build");
|
||||
}
|
||||
|
||||
const executablePath = path.join(context.appOutDir, EXECUTABLE_NAME);
|
||||
const wrappedBinaryPath = path.join(context.appOutDir, `${EXECUTABLE_NAME}.bin`);
|
||||
|
||||
if (!fs.existsSync(wrappedBinaryPath)) {
|
||||
if (!fs.existsSync(executablePath)) {
|
||||
throw new Error(`Expected Linux executable at ${executablePath}`);
|
||||
}
|
||||
|
||||
fs.renameSync(executablePath, wrappedBinaryPath);
|
||||
console.log(`Renamed ${EXECUTABLE_NAME} to ${EXECUTABLE_NAME}.bin for Linux wrapper`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(executablePath, WRAPPER_SCRIPT, { mode: WRAPPER_MODE });
|
||||
fs.chmodSync(executablePath, WRAPPER_MODE);
|
||||
console.log(`Created Linux wrapper for ${EXECUTABLE_NAME} with --no-sandbox`);
|
||||
};
|
||||
@@ -85,7 +85,11 @@ export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null
|
||||
const startIndex = process.defaultApp ? 2 : 1;
|
||||
const effective = argv
|
||||
.slice(startIndex)
|
||||
.filter((arg) => !arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX));
|
||||
.filter(
|
||||
(arg) =>
|
||||
!arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX) &&
|
||||
!arg.startsWith("--"),
|
||||
);
|
||||
|
||||
return effective.length > 0 ? effective : null;
|
||||
}
|
||||
|
||||
@@ -1588,6 +1588,34 @@ describe("AgentManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("notifyAgentState does not mutate updatedAt", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const before = manager.getAgent(snapshot.id);
|
||||
expect(before).toBeDefined();
|
||||
|
||||
manager.notifyAgentState(snapshot.id);
|
||||
|
||||
const after = manager.getAgent(snapshot.id);
|
||||
expect(after).toBeDefined();
|
||||
expect(after!.updatedAt.getTime()).toBe(before!.updatedAt.getTime());
|
||||
});
|
||||
|
||||
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -247,6 +247,7 @@ type LiveEventStreamingSession = AgentSession & {
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
|
||||
const RECENT_USER_MESSAGE_REPLAY_WINDOW_MS = 15_000;
|
||||
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
|
||||
const AgentIdSchema = z.string().uuid();
|
||||
|
||||
@@ -847,7 +848,6 @@ export class AgentManager {
|
||||
if (!agent || agent.internal) {
|
||||
return;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -931,6 +931,66 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
classifyRecordedUserMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { messageId?: string },
|
||||
): "new" | "duplicate" | "conflict" {
|
||||
const normalizedMessageId = normalizeMessageId(options?.messageId);
|
||||
if (!normalizedMessageId) {
|
||||
return "new";
|
||||
}
|
||||
|
||||
const agent = this.requireAgent(agentId);
|
||||
for (const row of agent.timelineRows) {
|
||||
if (row.item.type !== "user_message") {
|
||||
continue;
|
||||
}
|
||||
const rowMessageId = normalizeMessageId(row.item.messageId);
|
||||
if (rowMessageId !== normalizedMessageId) {
|
||||
continue;
|
||||
}
|
||||
return row.item.text === text ? "duplicate" : "conflict";
|
||||
}
|
||||
|
||||
if (this.isRecentUserMessageReplay(agent, text)) {
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
return "new";
|
||||
}
|
||||
|
||||
private isRecentUserMessageReplay(agent: ManagedAgent, text: string): boolean {
|
||||
let latestUserMessageRow: AgentTimelineRow | null = null;
|
||||
for (let index = agent.timelineRows.length - 1; index >= 0; index -= 1) {
|
||||
const row = agent.timelineRows[index];
|
||||
if (!row || row.item.type !== "user_message") {
|
||||
continue;
|
||||
}
|
||||
latestUserMessageRow = row;
|
||||
break;
|
||||
}
|
||||
|
||||
const latestUserMessageItem =
|
||||
latestUserMessageRow?.item.type === "user_message" ? latestUserMessageRow.item : null;
|
||||
if (!latestUserMessageRow || !latestUserMessageItem || latestUserMessageItem.text !== text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (agent.lifecycle === "running" || agent.pendingRun || agent.pendingReplacement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rowTimestampMs = Date.parse(latestUserMessageRow.timestamp);
|
||||
const latestActivityMs = Math.max(
|
||||
Number.isFinite(rowTimestampMs) ? rowTimestampMs : 0,
|
||||
agent.updatedAt.getTime(),
|
||||
agent.lastUserMessageAt?.getTime() ?? 0,
|
||||
);
|
||||
|
||||
return Date.now() - latestActivityMs <= RECENT_USER_MESSAGE_REPLAY_WINDOW_MS;
|
||||
}
|
||||
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.touchUpdatedAt(agent);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
import {
|
||||
__codexAppServerInternals,
|
||||
@@ -97,4 +98,47 @@ describe("Codex app-server provider", () => {
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not buffer terminal Codex events before turn_started", () => {
|
||||
const terminalEvents: AgentStreamEvent[] = [
|
||||
{ type: "turn_completed", provider: "codex", usage: undefined },
|
||||
{ type: "turn_failed", provider: "codex", error: "boom" },
|
||||
{ type: "turn_canceled", provider: "codex", reason: "interrupted" },
|
||||
];
|
||||
|
||||
for (const event of terminalEvents) {
|
||||
expect(__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted(event)).toBe(false);
|
||||
}
|
||||
|
||||
expect(
|
||||
__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "stale chunk" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("only synthesizes completion from idle thread status while a run is active", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||
statusType: "idle",
|
||||
hasActiveRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||
statusType: "idle",
|
||||
hasActiveRun: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||
statusType: "running",
|
||||
hasActiveRun: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1195,6 +1195,18 @@ const ThreadStartedNotificationSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ThreadStatusChangedNotificationSchema = z
|
||||
.object({
|
||||
threadId: z.string().optional(),
|
||||
status: z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const TurnStartedNotificationSchema = z
|
||||
.object({
|
||||
turn: z.object({ id: z.string() }).passthrough(),
|
||||
@@ -1402,6 +1414,16 @@ const ItemFileChangeOutputDeltaNotificationSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ItemCommandExecutionOutputDeltaNotificationSchema = z
|
||||
.object({
|
||||
itemId: z.string().optional(),
|
||||
callId: z.string().optional(),
|
||||
stream: z.string().optional(),
|
||||
delta: z.string().optional(),
|
||||
chunk: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexEventTurnDiffNotificationSchema = z
|
||||
.object({
|
||||
msg: z
|
||||
@@ -1416,6 +1438,7 @@ const CodexEventTurnDiffNotificationSchema = z
|
||||
|
||||
type ParsedCodexNotification =
|
||||
| { kind: "thread_started"; threadId: string }
|
||||
| { kind: "thread_status_changed"; threadId: string | null; statusType: string | null }
|
||||
| { kind: "turn_started"; turnId: string }
|
||||
| { kind: "turn_completed"; status: string; errorMessage: string | null }
|
||||
| { kind: "plan_updated"; plan: Array<{ step: string | null; status: string | null }> }
|
||||
@@ -1499,6 +1522,25 @@ const CodexNotificationSchema = z.union([
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({
|
||||
method: z.literal("thread/status/changed"),
|
||||
params: ThreadStatusChangedNotificationSchema,
|
||||
})
|
||||
.transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "thread_status_changed",
|
||||
threadId: params.threadId ?? null,
|
||||
statusType: params.status?.type ?? null,
|
||||
}),
|
||||
),
|
||||
z.object({ method: z.literal("thread/status/changed"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({
|
||||
kind: "invalid_payload",
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema })
|
||||
.transform(
|
||||
@@ -1872,6 +1914,28 @@ const CodexNotificationSchema = z.union([
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({
|
||||
method: z.literal("item/commandExecution/outputDelta"),
|
||||
params: ItemCommandExecutionOutputDeltaNotificationSchema,
|
||||
})
|
||||
.transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "exec_command_output_delta",
|
||||
callId: params.callId ?? params.itemId ?? null,
|
||||
stream: params.stream ?? null,
|
||||
chunk: params.delta ?? params.chunk ?? null,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({ method: z.literal("item/commandExecution/outputDelta"), params: z.unknown() })
|
||||
.transform(
|
||||
({ method, params }): ParsedCodexNotification => ({
|
||||
kind: "invalid_payload",
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({
|
||||
method: z.literal("codex/event/turn_diff"),
|
||||
@@ -2034,8 +2098,37 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
|
||||
export const __codexAppServerInternals = {
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
shouldBufferCodexEventUntilTurnStarted,
|
||||
shouldSynthesizeTurnCompletionFromThreadStatus,
|
||||
};
|
||||
|
||||
function isTerminalAgentStreamEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
);
|
||||
}
|
||||
|
||||
function shouldBufferCodexEventUntilTurnStarted(event: AgentStreamEvent): boolean {
|
||||
if (
|
||||
event.type === "permission_requested" ||
|
||||
event.type === "permission_resolved" ||
|
||||
event.type === "turn_started" ||
|
||||
isTerminalAgentStreamEvent(event)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSynthesizeTurnCompletionFromThreadStatus(input: {
|
||||
statusType: string | null;
|
||||
hasActiveRun: boolean;
|
||||
}): boolean {
|
||||
return input.hasActiveRun && input.statusType === "idle";
|
||||
}
|
||||
|
||||
class CodexAppServerAgentSession implements AgentSession {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
@@ -2074,6 +2167,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
private runAwaitingTerminal = false;
|
||||
private connected = false;
|
||||
private collaborationModes: Array<{
|
||||
name: string;
|
||||
@@ -2500,33 +2594,29 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
||||
this.runAwaitingTerminal = true;
|
||||
|
||||
let sawTurnStarted = false;
|
||||
for await (const event of queue) {
|
||||
// Drop pre-start timeline noise that can leak from the previous turn.
|
||||
// Keep permission events, which can legitimately arrive before turn_started.
|
||||
// Keep permission and terminal events, which can legitimately arrive
|
||||
// before turn_started on resumed/replayed requests.
|
||||
if (!sawTurnStarted) {
|
||||
if (event.type === "permission_requested" || event.type === "permission_resolved") {
|
||||
yield event;
|
||||
if (shouldBufferCodexEventUntilTurnStarted(event)) {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "turn_started") {
|
||||
sawTurnStarted = true;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield event;
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
if (isTerminalAgentStreamEvent(event)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.runAwaitingTerminal = false;
|
||||
if (this.eventQueue === queue) {
|
||||
this.eventQueue = null;
|
||||
}
|
||||
@@ -2860,6 +2950,31 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "thread_status_changed") {
|
||||
if (
|
||||
shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||
statusType: parsed.statusType,
|
||||
hasActiveRun: this.runAwaitingTerminal,
|
||||
})
|
||||
) {
|
||||
this.runAwaitingTerminal = false;
|
||||
this.currentTurnId = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.emittedExecCommandStartedCallIds.clear();
|
||||
this.emittedExecCommandCompletedCallIds.clear();
|
||||
this.pendingCommandOutputDeltas.clear();
|
||||
this.pendingFileChangeOutputDeltas.clear();
|
||||
this.warnedIncompleteEditToolCallIds.clear();
|
||||
this.emitEvent({
|
||||
type: "turn_completed",
|
||||
provider: CODEX_PROVIDER,
|
||||
usage: this.latestUsage,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "turn_started") {
|
||||
this.currentTurnId = parsed.turnId;
|
||||
this.emittedItemStartedIds.clear();
|
||||
@@ -2874,6 +2989,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (parsed.kind === "turn_completed") {
|
||||
this.runAwaitingTerminal = false;
|
||||
this.currentTurnId = null;
|
||||
if (parsed.status === "failed") {
|
||||
this.emitEvent({
|
||||
type: "turn_failed",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "send-message-id-replay-"));
|
||||
}
|
||||
|
||||
describe("send_agent_message_request replay", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60_000);
|
||||
|
||||
test("does not start a second run when the same client messageId is replayed", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "duplicate message replay",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
const messageId = "msg-replay-1";
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId });
|
||||
await ctx.client.waitForFinish(agent.id, 5_000);
|
||||
|
||||
const afterFirst = await ctx.client.fetchAgent(agent.id);
|
||||
expect(afterFirst?.agent.status).toBe("idle");
|
||||
const firstUpdatedAt = afterFirst?.agent.updatedAt ?? null;
|
||||
expect(firstUpdatedAt).not.toBeNull();
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const afterReplay = await ctx.client.fetchAgent(agent.id);
|
||||
expect(afterReplay?.agent.status).toBe("idle");
|
||||
expect(afterReplay?.agent.updatedAt ?? null).toBe(firstUpdatedAt);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("does not start a second run when identical text is replayed with a new messageId right after finish", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "duplicate text replay after finish",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId: "msg-1" });
|
||||
await ctx.client.waitForFinish(agent.id, 5_000);
|
||||
|
||||
const afterFirst = await ctx.client.fetchAgent(agent.id);
|
||||
expect(afterFirst?.agent.status).toBe("idle");
|
||||
const firstUpdatedAt = afterFirst?.agent.updatedAt ?? null;
|
||||
expect(firstUpdatedAt).not.toBeNull();
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId: "msg-2" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const afterReplay = await ctx.client.fetchAgent(agent.id);
|
||||
expect(afterReplay?.agent.status).toBe("idle");
|
||||
expect(afterReplay?.agent.updatedAt ?? null).toBe(firstUpdatedAt);
|
||||
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 20,
|
||||
projection: "canonical",
|
||||
});
|
||||
const userMessages = timeline.entries.filter(
|
||||
(entry) => entry.item.type === "user_message" && entry.item.text === "Reply with exactly hello",
|
||||
);
|
||||
expect(userMessages).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("does not replace an active run when identical text is resent with a new messageId", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "duplicate text replay while running",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Run: sleep 30", { messageId: "msg-running-1" });
|
||||
await ctx.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 5_000);
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Run: sleep 30", { messageId: "msg-running-2" });
|
||||
const afterReplay = await ctx.client.waitForFinish(agent.id, 5_000);
|
||||
expect(afterReplay.status).toBe("idle");
|
||||
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 20,
|
||||
projection: "canonical",
|
||||
});
|
||||
const userMessages = timeline.entries.filter(
|
||||
(entry) => entry.item.type === "user_message" && entry.item.text === "Run: sleep 30",
|
||||
);
|
||||
expect(userMessages).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2610,6 +2610,20 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
const replayDisposition = this.agentManager.classifyRecordedUserMessage(agentId, text, {
|
||||
messageId,
|
||||
});
|
||||
if (replayDisposition === "duplicate") {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, messageId },
|
||||
"Suppressing duplicate send replay for already-recorded user message",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (replayDisposition === "conflict") {
|
||||
throw new Error(`Client messageId '${messageId}' was reused with different text`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, text, {
|
||||
messageId,
|
||||
@@ -6626,6 +6640,38 @@ export class Session {
|
||||
|
||||
await this.ensureAgentLoaded(agentId);
|
||||
|
||||
const replayDisposition = this.agentManager.classifyRecordedUserMessage(agentId, msg.text, {
|
||||
messageId: msg.messageId,
|
||||
});
|
||||
if (replayDisposition === "duplicate") {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, messageId: msg.messageId, requestId: msg.requestId },
|
||||
"Suppressing duplicate send_agent_message_request replay",
|
||||
);
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (replayDisposition === "conflict") {
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
error: `Client messageId '${msg.messageId}' was reused with different text`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, msg.text, {
|
||||
messageId: msg.messageId,
|
||||
|
||||
Reference in New Issue
Block a user