Merge branch 'main' into dev
This commit is contained in:
+36
-30
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
Thanks for taking the time to contribute.
|
Thanks for taking the time to contribute.
|
||||||
|
|
||||||
|
## How this project works
|
||||||
|
|
||||||
|
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
||||||
|
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
||||||
|
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
||||||
|
|
||||||
|
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||||
|
|
||||||
|
## How to contribute
|
||||||
|
|
||||||
|
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
||||||
|
2. **Keep it small.** One bug, one flow, one focused change.
|
||||||
|
3. **Open a PR** once there is alignment on scope.
|
||||||
|
|
||||||
|
If you want to propose a direction change, start a conversation.
|
||||||
|
|
||||||
## Before you start
|
## Before you start
|
||||||
|
|
||||||
Please read these first:
|
Please read these first:
|
||||||
@@ -15,26 +35,16 @@ Please read these first:
|
|||||||
|
|
||||||
## What is most helpful
|
## What is most helpful
|
||||||
|
|
||||||
The highest-signal contributions right now are:
|
The most useful contributions right now are:
|
||||||
|
|
||||||
- bug fixes
|
- bug fixes
|
||||||
|
- windows and linux specific fixes
|
||||||
- regression fixes
|
- regression fixes
|
||||||
- docs improvements
|
- doc improvements
|
||||||
- packaging / platform fixes
|
- packaging / platform fixes
|
||||||
- focused UX improvements that fit the existing product direction
|
- focused UX improvements that fit the existing product direction
|
||||||
- tests that lock down important behavior
|
- tests that lock down important behavior
|
||||||
|
|
||||||
## Discuss large changes first
|
|
||||||
|
|
||||||
If you want to add a major feature, change core UX, introduce a new surface, or bring in a new architectural concept, please open an issue or start a conversation first.
|
|
||||||
|
|
||||||
Even if the code is good, large unsolicited PRs are unlikely to be merged if they set product direction without prior alignment.
|
|
||||||
|
|
||||||
In short:
|
|
||||||
|
|
||||||
- small, focused PRs: great
|
|
||||||
- large product-shaping PRs without discussion: probably not
|
|
||||||
|
|
||||||
## Scope expectations
|
## Scope expectations
|
||||||
|
|
||||||
Please keep PRs narrow.
|
Please keep PRs narrow.
|
||||||
@@ -66,8 +76,10 @@ When reviewing contributions, the bar is not just:
|
|||||||
It is also:
|
It is also:
|
||||||
|
|
||||||
- does this fit Paseo?
|
- does this fit Paseo?
|
||||||
|
- does this add product surface that will be hard to maintain?
|
||||||
|
- does the value justify the maintenance surface it adds?
|
||||||
|
- does this solve a common need or over-serve an edge case?
|
||||||
- does this preserve the product's current direction?
|
- does this preserve the product's current direction?
|
||||||
- does this increase long-term complexity in a way that is worth it?
|
|
||||||
|
|
||||||
## Development setup
|
## Development setup
|
||||||
|
|
||||||
@@ -79,6 +91,7 @@ It is also:
|
|||||||
### Start local development
|
### Start local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# runs both daemon and expo app
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,9 +107,9 @@ npm run cli -- ls -a -g
|
|||||||
|
|
||||||
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
|
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||||
|
|
||||||
## Testing and verification
|
## Multi-platform testing
|
||||||
|
|
||||||
At minimum, run the checks relevant to your change.
|
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
||||||
|
|
||||||
Common checks:
|
Common checks:
|
||||||
|
|
||||||
@@ -117,16 +130,7 @@ If you touch protocol or shared client/server behavior, read the compatibility n
|
|||||||
|
|
||||||
## Coding standards
|
## Coding standards
|
||||||
|
|
||||||
Paseo has explicit standards. Please follow them.
|
Paseo has explicit standards. Follow them.
|
||||||
|
|
||||||
Highlights:
|
|
||||||
|
|
||||||
- keep complexity low
|
|
||||||
- avoid "while I'm at it" cleanup
|
|
||||||
- no `any`
|
|
||||||
- prefer object parameters over positional argument lists
|
|
||||||
- preserve behavior unless the change is explicitly meant to change behavior
|
|
||||||
- collocate tests with implementation
|
|
||||||
|
|
||||||
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
||||||
|
|
||||||
@@ -134,12 +138,14 @@ The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
|||||||
|
|
||||||
Before opening a PR, make sure:
|
Before opening a PR, make sure:
|
||||||
|
|
||||||
- the change is focused
|
- there was prior discussion and alignment on scope (issue or conversation)
|
||||||
|
- the change is focused, one idea per PR
|
||||||
- the PR description explains what changed and why
|
- the PR description explains what changed and why
|
||||||
- relevant docs were updated if needed
|
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
||||||
|
- UI changes have been tested on mobile and web at minimum
|
||||||
- typecheck passes
|
- typecheck passes
|
||||||
- tests pass, or you clearly explain what could not be run
|
- tests pass, or you clearly explain what could not be run
|
||||||
- the change does not accidentally bundle unrelated product ideas
|
- relevant docs were updated if needed
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
|
|
||||||
@@ -153,7 +159,7 @@ That is especially true for:
|
|||||||
- new orchestration models
|
- new orchestration models
|
||||||
- anything that would be hard to remove later
|
- anything that would be hard to remove later
|
||||||
|
|
||||||
Early alignment is much better than a large PR that is expensive for everyone to unwind.
|
Early alignment saves everyone time.
|
||||||
|
|
||||||
## Forks are fine
|
## Forks are fine
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
|
onModelSelectorOpen?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -110,6 +111,7 @@ export interface DraftAgentStatusBarProps {
|
|||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
|
onModelSelectorOpen?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +219,7 @@ function ControlledStatusBar({
|
|||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
|
onModelSelectorOpen,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -411,6 +414,7 @@ function ControlledStatusBar({
|
|||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -662,6 +666,7 @@ function ControlledStatusBar({
|
|||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
renderTrigger={({ selectedModelLabel }) => (
|
renderTrigger={({ selectedModelLabel }) => (
|
||||||
<View
|
<View
|
||||||
@@ -875,6 +880,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
isFetching: snapshotIsFetching,
|
isFetching: snapshotIsFetching,
|
||||||
|
invalidate: invalidateSnapshot,
|
||||||
} = useProvidersSnapshot(serverId);
|
} = useProvidersSnapshot(serverId);
|
||||||
|
|
||||||
const snapshotModels = useMemo(() => {
|
const snapshotModels = useMemo(() => {
|
||||||
@@ -1035,6 +1041,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||||
|
onModelSelectorOpen={invalidateSnapshot}
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
@@ -1061,6 +1068,7 @@ export function DraftAgentStatusBar({
|
|||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
|
onModelSelectorOpen,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -1105,6 +1113,7 @@ export function DraftAgentStatusBar({
|
|||||||
}}
|
}}
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
@@ -1154,6 +1163,7 @@ export function DraftAgentStatusBar({
|
|||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
features={features}
|
features={features}
|
||||||
onSetFeature={onSetFeature}
|
onSetFeature={onSetFeature}
|
||||||
|
onModelSelectorOpen={onModelSelectorOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
|
|
||||||
|
interface BranchSwitcherProps {
|
||||||
|
currentBranchName: string | null;
|
||||||
|
title: string;
|
||||||
|
branchOptions: ComboboxOption[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onBranchSelect: (branchId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchSwitcher({
|
||||||
|
currentBranchName,
|
||||||
|
title,
|
||||||
|
branchOptions,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
onBranchSelect,
|
||||||
|
}: BranchSwitcherProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
|
if (!currentBranchName) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
testID="workspace-header-title"
|
||||||
|
style={styles.headerTitle}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View ref={anchorRef} collapsable={false}>
|
||||||
|
<Pressable
|
||||||
|
testID="workspace-header-branch-switcher"
|
||||||
|
onPress={() => onOpenChange(true)}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.branchSwitcherTrigger,
|
||||||
|
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||||
|
>
|
||||||
|
<GitBranch
|
||||||
|
size={14}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
testID="workspace-header-title"
|
||||||
|
style={styles.headerTitle}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Combobox
|
||||||
|
options={branchOptions}
|
||||||
|
value={currentBranchName}
|
||||||
|
onSelect={onBranchSelect}
|
||||||
|
searchable
|
||||||
|
placeholder="Switch branch..."
|
||||||
|
searchPlaceholder="Filter branches..."
|
||||||
|
emptyText="No branches found."
|
||||||
|
title="Switch branch"
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
desktopPlacement="bottom-start"
|
||||||
|
desktopPreventInitialFlash
|
||||||
|
desktopMinWidth={280}
|
||||||
|
renderOption={({ option, selected, active, onPress }) => (
|
||||||
|
<ComboboxItem
|
||||||
|
key={option.id}
|
||||||
|
label={option.label}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
leadingSlot={
|
||||||
|
<GitBranch
|
||||||
|
size={14}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: {
|
||||||
|
xs: "400",
|
||||||
|
md: "300",
|
||||||
|
},
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
branchSwitcherTrigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
branchSwitcherTriggerHovered: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -59,6 +59,7 @@ interface CombinedModelSelectorProps {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
|
onOpen?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -517,6 +518,7 @@ export function CombinedModelSelector({
|
|||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
@@ -553,12 +555,14 @@ export function CombinedModelSelector({
|
|||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
setView(computeInitialView());
|
setView(computeInitialView());
|
||||||
if (!open) {
|
if (open) {
|
||||||
|
onOpen?.();
|
||||||
|
} else {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClose, computeInitialView],
|
[onOpen, onClose, computeInitialView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export function ToastViewport({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const isMobile = useIsCompactFormFactor();
|
||||||
const opacity = useRef(new Animated.Value(0)).current;
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
const translateY = useRef(new Animated.Value(-8)).current;
|
const translateY = useRef(new Animated.Value(-8)).current;
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -182,7 +183,6 @@ export function ToastViewport({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMobile = useIsCompactFormFactor();
|
|
||||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const topOffset =
|
const topOffset =
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildVisibleComboboxOptions,
|
buildVisibleComboboxOptions,
|
||||||
|
filterAndRankComboboxOptions,
|
||||||
getComboboxFallbackIndex,
|
getComboboxFallbackIndex,
|
||||||
orderVisibleComboboxOptions,
|
orderVisibleComboboxOptions,
|
||||||
} from "./combobox-options";
|
} from "./combobox-options";
|
||||||
@@ -47,6 +48,48 @@ describe("buildVisibleComboboxOptions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("filterAndRankComboboxOptions", () => {
|
||||||
|
const options = [
|
||||||
|
{ id: "feat/login", label: "feat/login" },
|
||||||
|
{ id: "main", label: "main" },
|
||||||
|
{ id: "feat/main-nav", label: "feat/main-nav" },
|
||||||
|
{ id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns all options when search is empty", () => {
|
||||||
|
expect(filterAndRankComboboxOptions(options, "")).toEqual(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by label substring", () => {
|
||||||
|
const result = filterAndRankComboboxOptions(options, "login");
|
||||||
|
expect(result.map((o) => o.id)).toEqual(["feat/login"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by id substring", () => {
|
||||||
|
const result = filterAndRankComboboxOptions(options, "fix/");
|
||||||
|
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by description substring", () => {
|
||||||
|
const result = filterAndRankComboboxOptions(options, "logout bug");
|
||||||
|
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks prefix matches above substring matches", () => {
|
||||||
|
const result = filterAndRankComboboxOptions(options, "main");
|
||||||
|
expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
const items = [{ id: "Alpha", label: "Alpha" }];
|
||||||
|
expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when nothing matches", () => {
|
||||||
|
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("combobox above-search ordering", () => {
|
describe("combobox above-search ordering", () => {
|
||||||
const visible = [
|
const visible = [
|
||||||
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
||||||
|
|||||||
@@ -35,18 +35,33 @@ export function shouldShowCustomComboboxOption(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterAndRankComboboxOptions(
|
||||||
|
options: ComboboxOptionModel[],
|
||||||
|
search: string,
|
||||||
|
): ComboboxOptionModel[] {
|
||||||
|
if (!search) return options;
|
||||||
|
return options
|
||||||
|
.filter(
|
||||||
|
(opt) =>
|
||||||
|
opt.label.toLowerCase().includes(search) ||
|
||||||
|
opt.id.toLowerCase().includes(search) ||
|
||||||
|
opt.description?.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aPrefix =
|
||||||
|
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
|
||||||
|
const bPrefix =
|
||||||
|
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
|
||||||
|
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildVisibleComboboxOptions(
|
export function buildVisibleComboboxOptions(
|
||||||
input: BuildVisibleComboboxOptionsInput,
|
input: BuildVisibleComboboxOptionsInput,
|
||||||
): ComboboxOptionModel[] {
|
): ComboboxOptionModel[] {
|
||||||
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
||||||
const filteredOptions = normalizedSearch
|
const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch);
|
||||||
? input.options.filter(
|
|
||||||
(opt) =>
|
|
||||||
opt.label.toLowerCase().includes(normalizedSearch) ||
|
|
||||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
|
||||||
opt.description?.toLowerCase().includes(normalizedSearch),
|
|
||||||
)
|
|
||||||
: input.options;
|
|
||||||
|
|
||||||
const sanitizedSearchValue = input.searchQuery.trim();
|
const sanitizedSearchValue = input.searchQuery.trim();
|
||||||
const showCustomOption = shouldShowCustomComboboxOption({
|
const showCustomOption = shouldShowCustomComboboxOption({
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export type UseAgentFormStateResult = {
|
|||||||
isModelLoading: boolean;
|
isModelLoading: boolean;
|
||||||
modelError: string | null;
|
modelError: string | null;
|
||||||
refreshProviderModels: () => void;
|
refreshProviderModels: () => void;
|
||||||
|
invalidateProviderModels: () => void;
|
||||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||||
workingDirIsEmpty: boolean;
|
workingDirIsEmpty: boolean;
|
||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
@@ -373,6 +374,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
isFetching: snapshotIsFetching,
|
isFetching: snapshotIsFetching,
|
||||||
error: snapshotError,
|
error: snapshotError,
|
||||||
refresh: refreshSnapshot,
|
refresh: refreshSnapshot,
|
||||||
|
invalidate: invalidateSnapshot,
|
||||||
} = useProvidersSnapshot(formState.serverId);
|
} = useProvidersSnapshot(formState.serverId);
|
||||||
|
|
||||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||||
@@ -646,6 +648,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
refreshSnapshot();
|
refreshSnapshot();
|
||||||
}, [refreshSnapshot]);
|
}, [refreshSnapshot]);
|
||||||
|
|
||||||
|
const invalidateProviderModels = useCallback(() => {
|
||||||
|
invalidateSnapshot();
|
||||||
|
}, [invalidateSnapshot]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const modelId = resolvedModel?.id ?? formState.model;
|
const modelId = resolvedModel?.id ?? formState.model;
|
||||||
@@ -712,6 +718,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
isModelLoading,
|
isModelLoading,
|
||||||
modelError,
|
modelError,
|
||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
|
invalidateProviderModels,
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
workingDirIsEmpty,
|
workingDirIsEmpty,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
@@ -743,6 +750,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
isModelLoading,
|
isModelLoading,
|
||||||
modelError,
|
modelError,
|
||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
|
invalidateProviderModels,
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
workingDirIsEmpty,
|
workingDirIsEmpty,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ function buildDraftStatusControls(input: {
|
|||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
|
onModelSelectorOpen: formState.invalidateProviderModels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import { useQuery, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||||
|
import type { ToastApi } from "@/components/toast-host";
|
||||||
|
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
|
||||||
|
interface UseBranchSwitcherInput {
|
||||||
|
client: DaemonClient | null;
|
||||||
|
normalizedServerId: string;
|
||||||
|
normalizedWorkspaceId: string;
|
||||||
|
currentBranchName: string | null;
|
||||||
|
isGitCheckout: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
toast: ToastApi;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBranchSwitcherResult {
|
||||||
|
branchOptions: ComboboxOption[];
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
handleBranchSelect: (branchId: string) => void;
|
||||||
|
invalidateStashAndCheckout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBranchSwitcher({
|
||||||
|
client,
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
|
currentBranchName,
|
||||||
|
isGitCheckout,
|
||||||
|
isConnected,
|
||||||
|
toast,
|
||||||
|
queryClient,
|
||||||
|
}: UseBranchSwitcherInput): UseBranchSwitcherResult {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const branchSuggestionsQuery = useQuery({
|
||||||
|
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Daemon client unavailable");
|
||||||
|
}
|
||||||
|
const payload = await client.getBranchSuggestions({
|
||||||
|
cwd: normalizedWorkspaceId,
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
return payload.branches ?? [];
|
||||||
|
},
|
||||||
|
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const branchOptions = useMemo<ComboboxOption[]>(() => {
|
||||||
|
const branches = branchSuggestionsQuery.data ?? [];
|
||||||
|
return branches.map((name) => ({ id: name, label: name }));
|
||||||
|
}, [branchSuggestionsQuery.data]);
|
||||||
|
|
||||||
|
const stashListQueryKey = useMemo(
|
||||||
|
() => ["stashList", normalizedServerId, normalizedWorkspaceId] as const,
|
||||||
|
[normalizedServerId, normalizedWorkspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidateStashAndCheckout = useCallback(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]);
|
||||||
|
|
||||||
|
const stashAndSwitch = useCallback(
|
||||||
|
async (branchId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const shouldStash = await confirmDialog({
|
||||||
|
title: "Uncommitted changes",
|
||||||
|
message:
|
||||||
|
"You have uncommitted changes. Stash them before switching branches?",
|
||||||
|
confirmLabel: "Stash & Switch",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
});
|
||||||
|
if (!shouldStash) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stashPayload = await client.stashSave(normalizedWorkspaceId, {
|
||||||
|
branch: currentBranchName ?? undefined,
|
||||||
|
});
|
||||||
|
if (stashPayload.error) {
|
||||||
|
toast.error(stashPayload.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await invalidateStashAndCheckout();
|
||||||
|
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
||||||
|
if (switchPayload.error) {
|
||||||
|
toast.error(switchPayload.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await invalidateStashAndCheckout();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to stash changes");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBranchSelect = useCallback(
|
||||||
|
(branchId: string) => {
|
||||||
|
if (branchId === currentBranchName) return;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
|
||||||
|
if (payload.error) {
|
||||||
|
// If the error is about uncommitted changes, offer the stash dialog
|
||||||
|
if (payload.error.message.toLowerCase().includes("uncommitted")) {
|
||||||
|
await stashAndSwitch(branchId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(payload.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Success — refresh and check for stashes on the target branch
|
||||||
|
await invalidateStashAndCheckout();
|
||||||
|
try {
|
||||||
|
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true });
|
||||||
|
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
|
||||||
|
if (targetStash) {
|
||||||
|
const shouldRestore = await confirmDialog({
|
||||||
|
title: "Restore stashed changes?",
|
||||||
|
message: "This branch has stashed changes from a previous session. Would you like to restore them?",
|
||||||
|
confirmLabel: "Restore",
|
||||||
|
cancelLabel: "Later",
|
||||||
|
});
|
||||||
|
if (shouldRestore) {
|
||||||
|
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
|
||||||
|
if (popPayload.error) {
|
||||||
|
toast.error(popPayload.error.message);
|
||||||
|
} else {
|
||||||
|
toast.show("Stashed changes restored");
|
||||||
|
}
|
||||||
|
await invalidateStashAndCheckout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — user can still restore on next branch switch
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to switch branch");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, stashAndSwitch, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ interface UseProvidersSnapshotResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
supportsSnapshot: boolean;
|
supportsSnapshot: boolean;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
|
invalidate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
|
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
|
||||||
@@ -66,6 +67,10 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
|||||||
void client.refreshProvidersSnapshot();
|
void client.refreshProvidersSnapshot();
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
|
const invalidate = useCallback(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey });
|
||||||
|
}, [queryClient, queryKey]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entries: snapshotQuery.data?.entries ?? undefined,
|
entries: snapshotQuery.data?.entries ?? undefined,
|
||||||
isLoading: snapshotQuery.isLoading,
|
isLoading: snapshotQuery.isLoading,
|
||||||
@@ -73,6 +78,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
|||||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||||
supportsSnapshot,
|
supportsSnapshot,
|
||||||
refresh,
|
refresh,
|
||||||
|
invalidate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,28 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
paddingTop: theme.spacing[16],
|
paddingTop: theme.spacing[16],
|
||||||
},
|
},
|
||||||
|
errorScreen: {
|
||||||
|
position: "relative",
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
errorScrollView: {
|
||||||
|
flex: 1,
|
||||||
|
...(Platform.OS === "web"
|
||||||
|
? {
|
||||||
|
overflowX: "auto",
|
||||||
|
overflowY: "auto",
|
||||||
|
}
|
||||||
|
: null),
|
||||||
|
},
|
||||||
|
errorScrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
paddingHorizontal: theme.spacing[8],
|
||||||
|
paddingVertical: theme.spacing[8],
|
||||||
|
paddingTop: theme.spacing[16],
|
||||||
|
},
|
||||||
centeredContent: {
|
centeredContent: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -255,67 +277,73 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, styles.containerError]}>
|
<View style={styles.errorScreen}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
<View style={styles.errorContent}>
|
<ScrollView
|
||||||
<View style={styles.errorHeader}>
|
style={styles.errorScrollView}
|
||||||
<PaseoLogo size={64} />
|
contentContainerStyle={styles.errorScrollContent}
|
||||||
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
showsVerticalScrollIndicator
|
||||||
|
>
|
||||||
|
<View style={styles.errorContent}>
|
||||||
|
<View style={styles.errorHeader}>
|
||||||
|
<PaseoLogo size={64} />
|
||||||
|
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.errorDescription}>
|
||||||
|
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.errorMessage}>
|
||||||
|
{bootstrapState.error}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.logsContainer}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.logsScroll}
|
||||||
|
contentContainerStyle={styles.logsContent}
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
>
|
||||||
|
<Text selectable style={styles.logsText}>
|
||||||
|
{logsText}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
|
||||||
|
onPress={handleCopyLogs}
|
||||||
|
>
|
||||||
|
Copy logs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
|
||||||
|
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
|
||||||
|
>
|
||||||
|
Open GitHub issue
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
|
||||||
|
onPress={() => void openExternalUrl(DOCS_URL)}
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
|
||||||
|
onPress={bootstrapState.retry}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
<Text style={styles.errorDescription}>
|
|
||||||
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text style={styles.errorMessage}>
|
|
||||||
{bootstrapState.error}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
|
||||||
|
|
||||||
<View style={styles.logsContainer}>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.logsScroll}
|
|
||||||
contentContainerStyle={styles.logsContent}
|
|
||||||
showsVerticalScrollIndicator
|
|
||||||
>
|
|
||||||
<Text selectable style={styles.logsText}>
|
|
||||||
{logsText}
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.actionRow}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleCopyLogs}
|
|
||||||
>
|
|
||||||
Copy logs
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
|
|
||||||
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
|
|
||||||
>
|
|
||||||
Open GitHub issue
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
|
|
||||||
onPress={() => void openExternalUrl(DOCS_URL)}
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
|
|
||||||
onPress={bootstrapState.retry}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import invariant from "tiny-invariant";
|
|||||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||||
|
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +83,7 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
|
|||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
|
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
@@ -786,6 +788,24 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
||||||
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
branchOptions,
|
||||||
|
isOpen: isBranchSwitcherOpen,
|
||||||
|
setIsOpen: setIsBranchSwitcherOpen,
|
||||||
|
handleBranchSelect,
|
||||||
|
invalidateStashAndCheckout,
|
||||||
|
} = useBranchSwitcher({
|
||||||
|
client,
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
|
currentBranchName,
|
||||||
|
isGitCheckout,
|
||||||
|
isConnected,
|
||||||
|
toast,
|
||||||
|
queryClient,
|
||||||
|
});
|
||||||
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
@@ -2018,13 +2038,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text
|
<BranchSwitcher
|
||||||
testID="workspace-header-title"
|
currentBranchName={currentBranchName}
|
||||||
style={styles.headerTitle}
|
title={workspaceHeader.title}
|
||||||
numberOfLines={1}
|
branchOptions={branchOptions}
|
||||||
>
|
isOpen={isBranchSwitcherOpen}
|
||||||
{workspaceHeader.title}
|
onOpenChange={setIsBranchSwitcherOpen}
|
||||||
</Text>
|
onBranchSelect={handleBranchSelect}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
testID="workspace-header-subtitle"
|
testID="workspace-header-subtitle"
|
||||||
style={styles.headerProjectTitle}
|
style={styles.headerProjectTitle}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => {
|
|||||||
).toBe("attention");
|
).toBe("attention");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats initializing agents as running", () => {
|
it("treats initializing agents as done", () => {
|
||||||
expect(
|
expect(
|
||||||
deriveSidebarStateBucket({
|
deriveSidebarStateBucket({
|
||||||
status: "initializing",
|
status: "initializing",
|
||||||
@@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => {
|
|||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
}),
|
}),
|
||||||
).toBe("running");
|
).toBe("done");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function deriveSidebarStateBucket(input: {
|
|||||||
if (input.status === "error" || input.attentionReason === "error") {
|
if (input.status === "error" || input.attentionReason === "error") {
|
||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
if (input.status === "running" || input.status === "initializing") {
|
if (input.status === "running") {
|
||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
if (input.requiresAttention) {
|
if (input.requiresAttention) {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import type {
|
|||||||
CheckoutPushResponse,
|
CheckoutPushResponse,
|
||||||
CheckoutPrCreateResponse,
|
CheckoutPrCreateResponse,
|
||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
|
CheckoutSwitchBranchResponse,
|
||||||
|
StashSaveResponse,
|
||||||
|
StashPopResponse,
|
||||||
|
StashListResponse,
|
||||||
ValidateBranchResponse,
|
ValidateBranchResponse,
|
||||||
BranchSuggestionsResponse,
|
BranchSuggestionsResponse,
|
||||||
GitHubSearchResponse,
|
GitHubSearchResponse,
|
||||||
@@ -224,6 +228,10 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
|||||||
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
||||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
|
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
||||||
|
type StashSavePayload = StashSaveResponse["payload"];
|
||||||
|
type StashPopPayload = StashPopResponse["payload"];
|
||||||
|
type StashListPayload = StashListResponse["payload"];
|
||||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||||
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
||||||
type GitHubSearchPayload = GitHubSearchResponse["payload"];
|
type GitHubSearchPayload = GitHubSearchResponse["payload"];
|
||||||
@@ -2420,6 +2428,74 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkoutSwitchBranch(
|
||||||
|
cwd: string,
|
||||||
|
branch: string,
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<CheckoutSwitchBranchPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "checkout_switch_branch_request",
|
||||||
|
cwd,
|
||||||
|
branch,
|
||||||
|
},
|
||||||
|
responseType: "checkout_switch_branch_response",
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stashSave(
|
||||||
|
cwd: string,
|
||||||
|
options?: { branch?: string },
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<StashSavePayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "stash_save_request",
|
||||||
|
cwd,
|
||||||
|
branch: options?.branch,
|
||||||
|
},
|
||||||
|
responseType: "stash_save_response",
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stashPop(
|
||||||
|
cwd: string,
|
||||||
|
stashIndex: number,
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<StashPopPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "stash_pop_request",
|
||||||
|
cwd,
|
||||||
|
stashIndex,
|
||||||
|
},
|
||||||
|
responseType: "stash_pop_response",
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stashList(
|
||||||
|
cwd: string,
|
||||||
|
options?: { paseoOnly?: boolean },
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<StashListPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "stash_list_request",
|
||||||
|
cwd,
|
||||||
|
paseoOnly: options?.paseoOnly,
|
||||||
|
},
|
||||||
|
responseType: "stash_list_response",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getPaseoWorktreeList(
|
async getPaseoWorktreeList(
|
||||||
input: { cwd?: string; repoRoot?: string },
|
input: { cwd?: string; repoRoot?: string },
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export async function createPaseoDaemon(
|
|||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && allowedOrigins.has(origin)) {
|
if (origin && (allowedOrigins.has("*") || allowedOrigins.has(origin))) {
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
|||||||
@@ -1664,6 +1664,22 @@ export class Session {
|
|||||||
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "checkout_switch_branch_request":
|
||||||
|
await this.handleCheckoutSwitchBranchRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stash_save_request":
|
||||||
|
await this.handleStashSaveRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stash_pop_request":
|
||||||
|
await this.handleStashPopRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stash_list_request":
|
||||||
|
await this.handleStashListRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "checkout_commit_request":
|
case "checkout_commit_request":
|
||||||
await this.handleCheckoutCommitRequest(msg);
|
await this.handleCheckoutCommitRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -4404,6 +4420,139 @@ export class Session {
|
|||||||
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleCheckoutSwitchBranchRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { cwd, branch, requestId } = msg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.checkoutExistingBranch(cwd, branch);
|
||||||
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
|
||||||
|
// Push a workspace_update immediately so the sidebar/header reflect
|
||||||
|
// the new branch name without waiting for the background git watcher.
|
||||||
|
await this.emitWorkspaceUpdateForCwd(cwd);
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "checkout_switch_branch_response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
success: true,
|
||||||
|
branch,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit({
|
||||||
|
type: "checkout_switch_branch_response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
success: false,
|
||||||
|
branch,
|
||||||
|
error: toCheckoutError(error),
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stash handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
|
||||||
|
|
||||||
|
private async handleStashSaveRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { cwd, requestId } = msg;
|
||||||
|
try {
|
||||||
|
const branchLabel = msg.branch?.trim() ?? "";
|
||||||
|
const message = branchLabel
|
||||||
|
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
|
||||||
|
: `${Session.PASEO_STASH_PREFIX} unnamed`;
|
||||||
|
await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
||||||
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
this.emit({
|
||||||
|
type: "stash_save_response",
|
||||||
|
payload: { cwd, success: true, error: null, requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit({
|
||||||
|
type: "stash_save_response",
|
||||||
|
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStashPopRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "stash_pop_request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { cwd, stashIndex, requestId } = msg;
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
||||||
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
this.emit({
|
||||||
|
type: "stash_pop_response",
|
||||||
|
payload: { cwd, success: true, error: null, requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit({
|
||||||
|
type: "stash_pop_response",
|
||||||
|
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStashListRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "stash_list_request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { cwd, requestId } = msg;
|
||||||
|
const paseoOnly = msg.paseoOnly !== false;
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
||||||
|
const entries: Array<{
|
||||||
|
index: number;
|
||||||
|
message: string;
|
||||||
|
branch: string | null;
|
||||||
|
isPaseo: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const sepIdx = line.indexOf("\0");
|
||||||
|
if (sepIdx < 0) continue;
|
||||||
|
const refPart = line.slice(0, sepIdx);
|
||||||
|
const subject = line.slice(sepIdx + 1);
|
||||||
|
const indexMatch = refPart.match(/\{(\d+)\}/);
|
||||||
|
if (!indexMatch) continue;
|
||||||
|
const index = Number(indexMatch[1]);
|
||||||
|
const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX);
|
||||||
|
const isPaseo = prefixIdx >= 0;
|
||||||
|
const branch = isPaseo
|
||||||
|
? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (paseoOnly && !isPaseo) continue;
|
||||||
|
entries.push({ index, message: subject, branch, isPaseo });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "stash_list_response",
|
||||||
|
payload: { cwd, entries, error: null, requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit({
|
||||||
|
type: "stash_list_response",
|
||||||
|
payload: { cwd, entries: [], error: toCheckoutError(error), requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleCheckoutCommitRequest(
|
private async handleCheckoutCommitRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -5311,7 +5460,7 @@ export class Session {
|
|||||||
if (agent.status === "error" || agent.attentionReason === "error") {
|
if (agent.status === "error" || agent.attentionReason === "error") {
|
||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
if (agent.status === "running" || agent.status === "initializing") {
|
if (agent.status === "running") {
|
||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
if (agent.requiresAttention) {
|
if (agent.requiresAttention) {
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
!!requestHost &&
|
!!requestHost &&
|
||||||
(origin === `http://${requestHost}` || origin === `https://${requestHost}`);
|
(origin === `http://${requestHost}` || origin === `https://${requestHost}`);
|
||||||
|
|
||||||
if (!origin || allowedOrigins.has(origin) || sameOrigin) {
|
if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) {
|
||||||
callback(true);
|
callback(true);
|
||||||
} else {
|
} else {
|
||||||
this.incrementRuntimeCounter("originRejected");
|
this.incrementRuntimeCounter("originRejected");
|
||||||
|
|||||||
@@ -1066,6 +1066,37 @@ export const ValidateBranchRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutSwitchBranchRequestSchema = z.object({
|
||||||
|
type: z.literal("checkout_switch_branch_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
branch: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StashSaveRequestSchema = z.object({
|
||||||
|
type: z.literal("stash_save_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
/** Branch name to tag the stash with for later identification. */
|
||||||
|
branch: z.string().optional(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StashPopRequestSchema = z.object({
|
||||||
|
type: z.literal("stash_pop_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
/** Zero-based index from stash_list_response. */
|
||||||
|
stashIndex: z.number().int().min(0),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StashListRequestSchema = z.object({
|
||||||
|
type: z.literal("stash_list_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
/** If true, only return paseo-created stashes. Default true. */
|
||||||
|
paseoOnly: z.boolean().optional(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const BranchSuggestionsRequestSchema = z.object({
|
export const BranchSuggestionsRequestSchema = z.object({
|
||||||
type: z.literal("branch_suggestions_request"),
|
type: z.literal("branch_suggestions_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1436,6 +1467,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPushRequestSchema,
|
CheckoutPushRequestSchema,
|
||||||
CheckoutPrCreateRequestSchema,
|
CheckoutPrCreateRequestSchema,
|
||||||
CheckoutPrStatusRequestSchema,
|
CheckoutPrStatusRequestSchema,
|
||||||
|
CheckoutSwitchBranchRequestSchema,
|
||||||
|
StashSaveRequestSchema,
|
||||||
|
StashPopRequestSchema,
|
||||||
|
StashListRequestSchema,
|
||||||
ValidateBranchRequestSchema,
|
ValidateBranchRequestSchema,
|
||||||
BranchSuggestionsRequestSchema,
|
BranchSuggestionsRequestSchema,
|
||||||
GitHubSearchRequestSchema,
|
GitHubSearchRequestSchema,
|
||||||
@@ -2343,6 +2378,54 @@ export const CheckoutPrStatusResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutSwitchBranchResponseSchema = z.object({
|
||||||
|
type: z.literal("checkout_switch_branch_response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
branch: z.string(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const StashEntrySchema = z.object({
|
||||||
|
index: z.number().int().min(0),
|
||||||
|
message: z.string(),
|
||||||
|
branch: z.string().nullable(),
|
||||||
|
isPaseo: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StashSaveResponseSchema = z.object({
|
||||||
|
type: z.literal("stash_save_response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StashPopResponseSchema = z.object({
|
||||||
|
type: z.literal("stash_pop_response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StashListResponseSchema = z.object({
|
||||||
|
type: z.literal("stash_list_response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
entries: z.array(StashEntrySchema),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const ValidateBranchResponseSchema = z.object({
|
export const ValidateBranchResponseSchema = z.object({
|
||||||
type: z.literal("validate_branch_response"),
|
type: z.literal("validate_branch_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2745,6 +2828,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPushResponseSchema,
|
CheckoutPushResponseSchema,
|
||||||
CheckoutPrCreateResponseSchema,
|
CheckoutPrCreateResponseSchema,
|
||||||
CheckoutPrStatusResponseSchema,
|
CheckoutPrStatusResponseSchema,
|
||||||
|
CheckoutSwitchBranchResponseSchema,
|
||||||
|
StashSaveResponseSchema,
|
||||||
|
StashPopResponseSchema,
|
||||||
|
StashListResponseSchema,
|
||||||
ValidateBranchResponseSchema,
|
ValidateBranchResponseSchema,
|
||||||
BranchSuggestionsResponseSchema,
|
BranchSuggestionsResponseSchema,
|
||||||
GitHubSearchResponseSchema,
|
GitHubSearchResponseSchema,
|
||||||
@@ -2973,6 +3060,15 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
|
|||||||
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
||||||
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
||||||
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
||||||
|
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
|
||||||
|
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
|
||||||
|
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
|
||||||
|
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
|
||||||
|
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
|
||||||
|
export type StashPopResponse = z.infer<typeof StashPopResponseSchema>;
|
||||||
|
export type StashListRequest = z.infer<typeof StashListRequestSchema>;
|
||||||
|
export type StashListResponse = z.infer<typeof StashListResponseSchema>;
|
||||||
|
export type StashEntry = z.infer<typeof StashEntrySchema>;
|
||||||
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
||||||
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
||||||
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ type CheckoutFileChange = {
|
|||||||
isUntracked?: boolean;
|
isUntracked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BranchSuggestionRefOrigin = "local" | "remote";
|
|
||||||
|
|
||||||
function normalizeBranchSuggestionName(raw: string): string | null {
|
function normalizeBranchSuggestionName(raw: string): string | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
@@ -210,47 +209,57 @@ function normalizeBranchSuggestionName(raw: string): string | null {
|
|||||||
normalized = normalized.slice("origin/".length);
|
normalized = normalized.slice("origin/".length);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalized || normalized === "HEAD") {
|
if (!normalized || normalized === "HEAD" || normalized === "origin") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
|
interface GitRef {
|
||||||
const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, {
|
name: string;
|
||||||
cwd,
|
committerDate: number;
|
||||||
env: READ_ONLY_GIT_ENV,
|
}
|
||||||
});
|
|
||||||
|
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
||||||
|
const { stdout } = await execGit(
|
||||||
|
`git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`,
|
||||||
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
|
);
|
||||||
return stdout
|
return stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => {
|
||||||
.filter((line) => line.length > 0);
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const [name, dateStr] = trimmed.split("\t");
|
||||||
|
if (!name) return null;
|
||||||
|
return { name, committerDate: Number(dateStr) || 0 };
|
||||||
|
})
|
||||||
|
.filter((ref): ref is GitRef => ref !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortBranchSuggestions(
|
function sortBranchSuggestions(
|
||||||
branchNames: string[],
|
branchNames: string[],
|
||||||
localBranchNames: Set<string>,
|
branchMeta: Map<string, { isLocal: boolean; committerDate: number }>,
|
||||||
query: string,
|
query: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const hasQuery = normalizedQuery.length > 0;
|
const hasQuery = normalizedQuery.length > 0;
|
||||||
return branchNames.sort((a, b) => {
|
return branchNames.sort((a, b) => {
|
||||||
const aLower = a.toLowerCase();
|
|
||||||
const bLower = b.toLowerCase();
|
|
||||||
|
|
||||||
if (hasQuery) {
|
if (hasQuery) {
|
||||||
const aPrefix = aLower.startsWith(normalizedQuery);
|
const aPrefix = a.toLowerCase().startsWith(normalizedQuery);
|
||||||
const bPrefix = bLower.startsWith(normalizedQuery);
|
const bPrefix = b.toLowerCase().startsWith(normalizedQuery);
|
||||||
if (aPrefix !== bPrefix) {
|
if (aPrefix !== bPrefix) {
|
||||||
return aPrefix ? -1 : 1;
|
return aPrefix ? -1 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const aIsLocal = localBranchNames.has(a);
|
const aMeta = branchMeta.get(a);
|
||||||
const bIsLocal = localBranchNames.has(b);
|
const bMeta = branchMeta.get(b);
|
||||||
if (aIsLocal !== bIsLocal) {
|
const aDate = aMeta?.committerDate ?? 0;
|
||||||
return aIsLocal ? -1 : 1;
|
const bDate = bMeta?.committerDate ?? 0;
|
||||||
|
if (aDate !== bDate) {
|
||||||
|
return bDate - aDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
@@ -272,41 +281,40 @@ export async function listBranchSuggestions(
|
|||||||
listGitRefs(cwd, "refs/remotes/origin"),
|
listGitRefs(cwd, "refs/remotes/origin"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const merged = new Map<string, Set<BranchSuggestionRefOrigin>>();
|
const branchMeta = new Map<string, { isLocal: boolean; committerDate: number }>();
|
||||||
for (const localRef of localRefs) {
|
|
||||||
const normalized = normalizeBranchSuggestionName(localRef);
|
for (const ref of localRefs) {
|
||||||
if (!normalized) {
|
const normalized = normalizeBranchSuggestionName(ref.name);
|
||||||
continue;
|
if (!normalized) continue;
|
||||||
}
|
const existing = branchMeta.get(normalized);
|
||||||
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
branchMeta.set(normalized, {
|
||||||
origins.add("local");
|
isLocal: true,
|
||||||
merged.set(normalized, origins);
|
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
|
||||||
}
|
});
|
||||||
for (const remoteRef of remoteRefs) {
|
|
||||||
const normalized = normalizeBranchSuggestionName(remoteRef);
|
|
||||||
if (!normalized) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
|
||||||
origins.add("remote");
|
|
||||||
merged.set(normalized, origins);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredNames = Array.from(merged.keys()).filter((name) =>
|
for (const ref of remoteRefs) {
|
||||||
|
const normalized = normalizeBranchSuggestionName(ref.name);
|
||||||
|
if (!normalized) continue;
|
||||||
|
const existing = branchMeta.get(normalized);
|
||||||
|
if (!existing) {
|
||||||
|
branchMeta.set(normalized, { isLocal: false, committerDate: ref.committerDate });
|
||||||
|
} else {
|
||||||
|
branchMeta.set(normalized, {
|
||||||
|
...existing,
|
||||||
|
committerDate: Math.max(ref.committerDate, existing.committerDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredNames = Array.from(branchMeta.keys()).filter((name) =>
|
||||||
query ? name.toLowerCase().includes(query) : true,
|
query ? name.toLowerCase().includes(query) : true,
|
||||||
);
|
);
|
||||||
if (filteredNames.length === 0) {
|
if (filteredNames.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const localBranchNames = new Set<string>();
|
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
|
||||||
for (const [name, origins] of merged) {
|
|
||||||
if (origins.has("local")) {
|
|
||||||
localBranchNames.add(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
|
|
||||||
return ordered.slice(0, limit);
|
return ordered.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -39,7 +39,10 @@ echo "════════════════════════
|
|||||||
# through the daemon's Portless URL instead of a fixed localhost port.
|
# through the daemon's Portless URL instead of a fixed localhost port.
|
||||||
APP_ORIGIN="$(portless get app)"
|
APP_ORIGIN="$(portless get app)"
|
||||||
DAEMON_ENDPOINT="$(portless get daemon | sed -E 's#^https?://##')"
|
DAEMON_ENDPOINT="$(portless get daemon | sed -E 's#^https?://##')"
|
||||||
export PASEO_CORS_ORIGINS="${APP_ORIGIN}"
|
# Allow any origin in dev so Electron on random ports and Portless URLs all work.
|
||||||
|
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
|
||||||
|
# the daemon binds to localhost and this script is never used for production.
|
||||||
|
export PASEO_CORS_ORIGINS="*"
|
||||||
|
|
||||||
# Run both with concurrently
|
# Run both with concurrently
|
||||||
# BROWSER=none prevents auto-opening browser
|
# BROWSER=none prevents auto-opening browser
|
||||||
|
|||||||
Reference in New Issue
Block a user