Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -202,6 +202,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
adaptiveInputOutline: {
|
||||
outlineColor: theme.colors.accent,
|
||||
},
|
||||
adaptiveInputPlaceholder: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||
@@ -234,10 +240,23 @@ export type AdaptiveTextInputProps = TextInputProps & {
|
||||
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
|
||||
function AdaptiveTextInputInner(props, ref) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props;
|
||||
const {
|
||||
value: _value,
|
||||
initialValue,
|
||||
resetKey,
|
||||
defaultValue,
|
||||
style,
|
||||
placeholderTextColor,
|
||||
...inputProps
|
||||
} = props;
|
||||
// Recolor the browser's :focus-visible outline (defined in public/index.html)
|
||||
// so it matches the active theme's accent instead of its hard-coded fallback.
|
||||
// Consumer style wins if it sets outlineColor explicitly.
|
||||
const textInputProps = {
|
||||
...inputProps,
|
||||
defaultValue: initialValue ?? defaultValue,
|
||||
placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color,
|
||||
style: [styles.adaptiveInputOutline, style],
|
||||
};
|
||||
|
||||
if (isMobile && isNative) {
|
||||
|
||||
@@ -74,7 +74,10 @@ import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { normalizeInlinePathTarget } from "@/assistant-file-links";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
normalizeInlinePathTarget,
|
||||
} from "@/assistant-file-links";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
@@ -83,6 +86,7 @@ import {
|
||||
} from "@/workspace/file-open";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const handleInlinePathPress = useCallback(
|
||||
const handleInlinePathPress = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (!target.path) {
|
||||
return;
|
||||
@@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
checkout,
|
||||
});
|
||||
},
|
||||
[
|
||||
agent.cwd,
|
||||
agent.projectPlacement?.checkout?.isGit,
|
||||
isMobile,
|
||||
openFileExplorerForCheckout,
|
||||
onOpenWorkspaceFile,
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
setExplorerTabForCheckout,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleToolCallOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
},
|
||||
[handleInlinePathPress],
|
||||
);
|
||||
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
});
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
@@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
belowItem,
|
||||
});
|
||||
return (
|
||||
<AssistantMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
serverId={resolvedServerId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenWorkspaceFile={handleInlinePathPress}
|
||||
toast={toast}
|
||||
spacing={spacing}
|
||||
/>
|
||||
>
|
||||
<AssistantMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={resolvedServerId}
|
||||
client={client}
|
||||
spacing={spacing}
|
||||
/>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
);
|
||||
},
|
||||
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
|
||||
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
|
||||
);
|
||||
|
||||
const renderThoughtItem = useCallback(
|
||||
|
||||
@@ -68,9 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
@@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import {
|
||||
AssistantInlineCodePathLink,
|
||||
classifyAssistantFileLink,
|
||||
type AssistantFileLinkSource,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
type InlinePathTarget,
|
||||
useAssistantFileLinkResolver,
|
||||
useAssistantFileLinkActions,
|
||||
} from "@/assistant-file-links";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
@@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({
|
||||
interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
toast?: ToastApi | null;
|
||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
}
|
||||
|
||||
@@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
onInlinePathPress,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
client,
|
||||
toast,
|
||||
spacing = "default",
|
||||
}: AssistantMessageProps) {
|
||||
const markdownParser = useMemo(() => {
|
||||
@@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
return parser;
|
||||
}, []);
|
||||
|
||||
const fileLinkResolver = useAssistantFileLinkResolver({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile: onInlinePathPress,
|
||||
toast,
|
||||
const fileLinkActions = useAssistantFileLinkActions();
|
||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||
fileLinkActions.open({ href: url }, "main");
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleLinkPress = useCallback(
|
||||
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||
fileLinkResolver.open({ source, disposition });
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
const handleLinkPrefetch = useCallback(
|
||||
(source: AssistantFileLinkSource) => {
|
||||
fileLinkResolver.prefetch({ source });
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
const handleMarkdownLinkPress = useCallback(
|
||||
(url: string) => {
|
||||
fileLinkResolver.open({ source: { href: url }, disposition: "main" });
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
|
||||
const markdownRules = useMemo<RenderRules>(() => {
|
||||
return {
|
||||
text: (
|
||||
@@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
) => {
|
||||
const content = node.content ?? "";
|
||||
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
||||
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
|
||||
const inlineCodeSource: AssistantFileLinkSource = {
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
};
|
||||
const shouldResolveInlinePath =
|
||||
onInlinePathPress &&
|
||||
!isLinkedInlineCode &&
|
||||
inlineCodeFileLink &&
|
||||
inlineCodeFileLink.kind !== "external";
|
||||
!isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource);
|
||||
|
||||
if (shouldResolveInlinePath) {
|
||||
return (
|
||||
@@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={styles.code_inline}
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={styles.code_inline}
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{content}
|
||||
</AssistantMarkdownCodeLink>
|
||||
@@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
key={node.key}
|
||||
source={getMarkdownLinkSource(node)}
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
@@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
handleLinkPrefetch,
|
||||
handleLinkPress,
|
||||
markdownParser,
|
||||
onInlinePathPress,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
]);
|
||||
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
const keyedBlocks = useMemo(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
@@ -9,6 +9,7 @@ const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
copilot: CopilotIcon as unknown as typeof Bot,
|
||||
kiro: PackagePlus,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
pi: PiIcon as unknown as typeof Bot,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AdaptiveRenameModal } from "./rename-modal";
|
||||
|
||||
const { theme, adaptiveInputState } = vi.hoisted(() => ({
|
||||
adaptiveInputState: {
|
||||
latestProps: null as {
|
||||
onChangeText?: (next: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
} | null,
|
||||
},
|
||||
theme: {
|
||||
spacing: { 2: 8, 3: 12 },
|
||||
fontSize: { sm: 13, base: 15 },
|
||||
borderRadius: { md: 6 },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#555",
|
||||
palette: { red: { 300: "#f87171" } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", async () => {
|
||||
const ReactModule = await import("react");
|
||||
const AdaptiveModalSheet = ({
|
||||
visible,
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
return ReactModule.createElement(
|
||||
"div",
|
||||
{ "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title },
|
||||
ReactModule.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": "adaptive-modal-sheet-close",
|
||||
onClick: onClose,
|
||||
},
|
||||
"Close",
|
||||
),
|
||||
children,
|
||||
);
|
||||
};
|
||||
// Mirrors production AdaptiveTextInput: native-owned input seeded by
|
||||
// initialValue, remounted (via key) when resetKey changes so the new
|
||||
// initialValue takes effect.
|
||||
const AdaptiveTextInput = ReactModule.forwardRef<HTMLInputElement, Record<string, unknown>>(
|
||||
(props, ref) => {
|
||||
const p = props as {
|
||||
initialValue?: string;
|
||||
defaultValue?: string;
|
||||
editable?: boolean;
|
||||
maxLength?: number;
|
||||
testID?: string;
|
||||
onChangeText?: (next: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
};
|
||||
adaptiveInputState.latestProps = {
|
||||
onChangeText: p.onChangeText,
|
||||
onSubmitEditing: p.onSubmitEditing,
|
||||
};
|
||||
return ReactModule.createElement("input", {
|
||||
ref,
|
||||
defaultValue: p.initialValue ?? p.defaultValue ?? "",
|
||||
disabled: p.editable === false,
|
||||
maxLength: p.maxLength,
|
||||
"data-testid": p.testID,
|
||||
onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value),
|
||||
onKeyDown: (e: { key: string; preventDefault: () => void }) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
p.onSubmitEditing?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
return { AdaptiveModalSheet, AdaptiveTextInput };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const ReactModule = await import("react");
|
||||
return {
|
||||
Button: ({
|
||||
children,
|
||||
onPress,
|
||||
disabled,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}) =>
|
||||
ReactModule.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": testID,
|
||||
disabled: disabled || undefined,
|
||||
onClick: () => !disabled && onPress?.(),
|
||||
},
|
||||
children,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
vi.stubGlobal("document", dom.window.document);
|
||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||
vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement);
|
||||
vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent);
|
||||
vi.stubGlobal("Node", dom.window.Node);
|
||||
vi.stubGlobal("navigator", dom.window.navigator);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
adaptiveInputState.latestProps = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
interface RenderOptions {
|
||||
visible?: boolean;
|
||||
initialValue?: string;
|
||||
title?: string;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
onClose?: () => void;
|
||||
onSubmit?: (value: string) => Promise<void> | void;
|
||||
validate?: (value: string) => string | null;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
function renderModal(options: RenderOptions = {}): void {
|
||||
const {
|
||||
visible = true,
|
||||
initialValue = "",
|
||||
title = "Rename",
|
||||
placeholder,
|
||||
submitLabel,
|
||||
onClose = vi.fn(),
|
||||
onSubmit = vi.fn(),
|
||||
validate,
|
||||
maxLength,
|
||||
} = options;
|
||||
act(() => {
|
||||
root?.render(
|
||||
<AdaptiveRenameModal
|
||||
visible={visible}
|
||||
title={title}
|
||||
initialValue={initialValue}
|
||||
placeholder={placeholder}
|
||||
submitLabel={submitLabel}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
maxLength={maxLength}
|
||||
testID="rename-modal"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function queryInput(): HTMLInputElement | null {
|
||||
return document.querySelector<HTMLInputElement>('[data-testid="rename-modal-input"]');
|
||||
}
|
||||
|
||||
function querySubmit(): HTMLButtonElement | null {
|
||||
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-submit"]');
|
||||
}
|
||||
|
||||
function queryCancel(): HTMLButtonElement | null {
|
||||
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-cancel"]');
|
||||
}
|
||||
|
||||
function queryError(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('[data-testid="rename-modal-error"]');
|
||||
}
|
||||
|
||||
function click(element: Element | null): void {
|
||||
if (!element) throw new Error("Cannot click null element");
|
||||
act(() => {
|
||||
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function typeInto(value: string): void {
|
||||
act(() => {
|
||||
adaptiveInputState.latestProps?.onChangeText?.(value);
|
||||
});
|
||||
}
|
||||
|
||||
function pressEnter(): void {
|
||||
act(() => {
|
||||
adaptiveInputState.latestProps?.onSubmitEditing?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("RenameModal", () => {
|
||||
it("renders with the initial value pre-filled and selects it after open", async () => {
|
||||
vi.useFakeTimers();
|
||||
renderModal({ initialValue: "main" });
|
||||
const input = queryInput();
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.value).toBe("main");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
const focused = document.activeElement as HTMLInputElement | null;
|
||||
expect(focused).toBe(input);
|
||||
expect(focused?.selectionStart).toBe(0);
|
||||
expect(focused?.selectionEnd).toBe("main".length);
|
||||
});
|
||||
|
||||
it("submits on Enter keypress in the input when the value has changed", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ initialValue: "feature", onSubmit, onClose });
|
||||
|
||||
typeInto("feature-2");
|
||||
pressEnter();
|
||||
await flush();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith("feature-2");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when AdaptiveModalSheet's close prop fires (cancel button / backdrop delegated)", () => {
|
||||
const onClose = vi.fn();
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ initialValue: "main", onClose, onSubmit });
|
||||
|
||||
click(document.querySelector('[data-testid="adaptive-modal-sheet-close"]'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables submit when draft equals initialValue and re-enables after a change", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ initialValue: "main", onSubmit });
|
||||
|
||||
expect(querySubmit()?.disabled).toBe(true);
|
||||
|
||||
pressEnter();
|
||||
await flush();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
typeInto("main-v2");
|
||||
await flush();
|
||||
expect(querySubmit()?.disabled).toBe(false);
|
||||
|
||||
typeInto("main");
|
||||
await flush();
|
||||
expect(querySubmit()?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces validate errors inline and blocks submission", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const validate = vi.fn((value: string) => (value === "bad" ? "Invalid name" : null));
|
||||
renderModal({ initialValue: "ok", validate, onSubmit });
|
||||
|
||||
typeInto("bad");
|
||||
const submit = querySubmit()!;
|
||||
expect(submit.disabled).toBe(true);
|
||||
|
||||
pressEnter();
|
||||
await flush();
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
const errorNode = queryError();
|
||||
expect(errorNode?.textContent).toContain("Invalid name");
|
||||
});
|
||||
|
||||
it("disables the submit button while onSubmit is pending", async () => {
|
||||
let resolve: () => void = () => {};
|
||||
const onSubmit = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
renderModal({ initialValue: "main", onSubmit });
|
||||
|
||||
typeInto("main-renamed");
|
||||
click(querySubmit());
|
||||
await flush();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(querySubmit()?.disabled).toBe(true);
|
||||
expect(queryCancel()?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the modal open with an error when onSubmit rejects", async () => {
|
||||
const onSubmit = vi.fn().mockRejectedValue(new Error("Server said no"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ initialValue: "main", onSubmit, onClose });
|
||||
|
||||
typeInto("main-renamed");
|
||||
click(querySubmit());
|
||||
await flush();
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(queryError()?.textContent).toContain("Server said no");
|
||||
expect(querySubmit()?.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export interface AdaptiveRenameModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
initialValue: string;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (value: string) => Promise<void> | void;
|
||||
validate?: (value: string) => string | null;
|
||||
maxLength?: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function AdaptiveRenameModal({
|
||||
visible,
|
||||
title,
|
||||
initialValue,
|
||||
placeholder,
|
||||
submitLabel = "Rename",
|
||||
onClose,
|
||||
onSubmit,
|
||||
validate,
|
||||
maxLength,
|
||||
testID,
|
||||
}: AdaptiveRenameModalProps) {
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setDraft(initialValue);
|
||||
setError(null);
|
||||
setIsPending(false);
|
||||
}, [visible, initialValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const length = initialValue.length;
|
||||
const timeout = setTimeout(() => {
|
||||
const node = inputRef.current;
|
||||
if (!node) return;
|
||||
node.focus();
|
||||
if (isWeb && node instanceof HTMLInputElement) {
|
||||
node.setSelectionRange(0, length);
|
||||
} else if (!isWeb && length > 0) {
|
||||
node.setNativeProps({ selection: { start: 0, end: length } });
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [visible, initialValue]);
|
||||
|
||||
const computeError = useCallback(
|
||||
(value: string): string | null => {
|
||||
if (!value.trim()) return "Name is required";
|
||||
return validate ? validate(value) : null;
|
||||
},
|
||||
[validate],
|
||||
);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
setDraft(value);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (isPending) return;
|
||||
const value = draft;
|
||||
if (value === initialValue) return;
|
||||
const validationError = computeError(value);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsPending(true);
|
||||
await onSubmit(value);
|
||||
setIsPending(false);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setIsPending(false);
|
||||
const message = err instanceof Error && err.message ? err.message : "Unable to save";
|
||||
setError(message);
|
||||
}
|
||||
}, [isPending, draft, initialValue, computeError, onSubmit, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isPending) return;
|
||||
onClose();
|
||||
}, [isPending, onClose]);
|
||||
|
||||
const handleSubmitVoid = useCallback(() => {
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
const submitDisabled = isPending || draft === initialValue || computeError(draft) !== null;
|
||||
const inputTestID = testID ? `${testID}-input` : undefined;
|
||||
const errorTestID = testID ? `${testID}-error` : undefined;
|
||||
const submitTestID = testID ? `${testID}-submit` : undefined;
|
||||
const cancelTestID = testID ? `${testID}-cancel` : undefined;
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title }), [title]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={handleCancel}
|
||||
header={sheetHeader}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.body}>
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialValue}
|
||||
onChangeText={handleChange}
|
||||
placeholder={placeholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isPending}
|
||||
maxLength={maxLength}
|
||||
onSubmitEditing={handleSubmitVoid}
|
||||
style={styles.input}
|
||||
testID={inputTestID}
|
||||
/>
|
||||
{error ? (
|
||||
<Text style={styles.errorText} testID={errorTestID}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={styles.actionButton}
|
||||
onPress={handleCancel}
|
||||
disabled={isPending}
|
||||
testID={cancelTestID}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
style={styles.actionButton}
|
||||
onPress={handleSubmitVoid}
|
||||
disabled={submitDisabled}
|
||||
testID={submitTestID}
|
||||
>
|
||||
{isPending ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: {
|
||||
gap: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
color: theme.colors.foreground,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -45,6 +48,7 @@ import {
|
||||
SquareTerminal,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react-native";
|
||||
@@ -147,6 +151,7 @@ const ThemedTrash2 = withUnistyles(Trash2);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -232,6 +237,7 @@ interface WorkspaceRowInnerProps {
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
@@ -565,6 +571,7 @@ const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColor
|
||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
@@ -640,6 +647,7 @@ function WorkspaceRowRightGroup({
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isHovered: boolean;
|
||||
@@ -656,6 +664,7 @@ function WorkspaceRowRightGroup({
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
}) {
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
return (
|
||||
@@ -675,6 +684,7 @@ function WorkspaceRowRightGroup({
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -701,6 +711,7 @@ function WorkspaceKebabMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -710,6 +721,7 @@ function WorkspaceKebabMenu({
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
@@ -750,6 +762,15 @@ function WorkspaceKebabMenu({
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
Rename workspace
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
@@ -1319,6 +1340,7 @@ function WorkspaceRowInner({
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
@@ -1429,6 +1451,7 @@ function WorkspaceRowInner({
|
||||
onArchive={onArchive}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
/>
|
||||
</View>
|
||||
{prHint ? (
|
||||
@@ -1469,7 +1492,9 @@ function WorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -1599,6 +1624,51 @@ function WorkspaceRowWithMenu({
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (branch: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const payload = await client.renameBranch({ cwd: targetCwd, branch });
|
||||
if (!payload.success || payload.error) {
|
||||
throw new Error(payload.error?.message ?? "Failed to rename branch");
|
||||
}
|
||||
return { targetCwd };
|
||||
},
|
||||
onSuccess: async ({ targetCwd }) => {
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||
serverId: workspace.serverId,
|
||||
cwd: targetCwd,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseRename = useCallback(() => {
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(slugify(value));
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const validateRenameSlug = useCallback((value: string): string | null => {
|
||||
const result = validateBranchSlug(slugify(value));
|
||||
if (result.valid) return null;
|
||||
return result.error ?? "Invalid branch name";
|
||||
}, []);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -1617,26 +1687,41 @@ function WorkspaceRowWithMenu({
|
||||
});
|
||||
|
||||
return (
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={canCopyBranchName ? handleOpenRename : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.name}
|
||||
placeholder="branch-name"
|
||||
submitLabel="Rename"
|
||||
validate={validateRenameSlug}
|
||||
maxLength={MAX_SLUG_LENGTH}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ interface SplitContainerProps {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
@@ -362,6 +363,7 @@ export function SplitContainer({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -577,6 +579,7 @@ export function SplitContainer({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -716,6 +719,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -768,6 +772,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -813,6 +818,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -864,6 +870,7 @@ function SplitPaneView({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -1004,6 +1011,7 @@ function SplitPaneView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
|
||||
Reference in New Issue
Block a user