Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-05-19 18:12:20 +07:00
92 changed files with 6574 additions and 3521 deletions
@@ -1,6 +1,5 @@
export {
AssistantInlineCodePathLink,
AssistantInlinePathLink,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
@@ -9,5 +8,9 @@ export {
normalizeInlinePathTarget,
type InlinePathTarget,
} from "./parse";
export {
AssistantFileLinkResolverProvider,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkResolver } from "./use-resolver";
export { useAssistantFileLinkActions } from "./use-file-link";
+88 -184
View File
@@ -1,11 +1,4 @@
import {
useCallback,
useMemo,
useState,
type CSSProperties,
type ReactNode,
type MouseEvent,
} from "react";
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import {
Pressable,
Text,
@@ -16,128 +9,66 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
interface AssistantInlinePathLinkProps {
content: string;
parsed: InlinePathTarget;
onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
workspaceRoot?: string;
style: StyleProp<TextStyle>;
}
export function AssistantInlinePathLink({
content,
parsed,
onPress,
workspaceRoot,
style,
}: AssistantInlinePathLinkProps) {
const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]);
const handleAnchorClickCapture = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onPress(parsed, "side");
},
[onPress, parsed],
);
if (!isNative) {
return (
<FileLinkHoverTooltip filePath={formatInlinePathTargetForTooltip(parsed, workspaceRoot)}>
<a
href={parsed.path}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
{content}
</Text>
</a>
</FileLinkHoverTooltip>
);
}
return (
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
{content}
</Text>
);
}
import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource;
style: StyleProp<TextStyle>;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
export function AssistantMarkdownLink({
source,
style,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownLinkProps) {
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false);
const href = source.href;
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]);
const handleAnchorClickCapture = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onPress(source, "side");
},
[onPress, source],
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
[target, workspaceRoot],
);
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
const handleHoverIn = useCallback(() => {
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onAuxPress();
});
const handleHoverIn = useStableEvent(() => {
setHovered(true);
handlePrefetch();
}, [handlePrefetch]);
const handleHoverOut = useCallback(() => setHovered(false), []);
onHoverIn();
});
const handleHoverOut = useStableEvent(() => setHovered(false));
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
() => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered],
);
const tooltipFilePath = useMemo(
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
[source.href, workspaceRoot],
);
if (isNative) {
return (
<Text accessibilityRole="link" onPress={handlePress} style={style}>
{children}
</Text>
<FileLinkHoverTooltip filePath={tooltipPath}>
<Text accessibilityRole="link" onPress={onPress} style={style}>
{children}
</Text>
</FileLinkHoverTooltip>
);
}
const anchor = (
<a
href={href}
href={source.href}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Pressable
accessibilityRole="link"
onPress={handlePress}
onFocus={handlePrefetch}
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
@@ -146,10 +77,7 @@ export function AssistantMarkdownLink({
</a>
);
if (tooltipFilePath) {
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
}
return anchor;
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
}
interface AssistantMarkdownCodeLinkProps {
@@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps {
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
@@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownCodeLinkProps) {
const style = useMemo(
@@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink
source={source}
style={style}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
<AssistantMarkdownLink source={source} style={style}>
{children}
</AssistantMarkdownLink>
);
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
{content}
</AssistantMarkdownCodeLink>
);
}
function getMarkdownLinkTooltipFilePath(
href: string,
workspaceRoot: string | undefined,
): string | null {
const classification = classifyAssistantFileLink(href, { workspaceRoot });
if (classification?.kind !== "directFile") {
return null;
}
return formatInlinePathTargetForTooltip(classification.target, workspaceRoot);
}
function formatInlinePathTargetForTooltip(
target: InlinePathTarget,
target: { path: string; lineStart?: number; lineEnd?: number },
workspaceRoot: string | undefined,
): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot);
@@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und
return filePath;
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
>
{content}
</AssistantMarkdownCodeLink>
);
}
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
// the tooltip wrapper from breaking inline link flow.
@@ -284,7 +180,13 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
function FileLinkHoverTooltip({
filePath,
children,
}: {
filePath: string | null;
children: ReactNode;
}) {
if (!isWeb) {
return children;
}
@@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr
<TooltipTrigger asChild>
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
</TooltipTrigger>
<TooltipContent side="top" align="start" maxWidth={520}>
<View style={styles.tooltipBody}>
<Text selectable={false} style={styles.tooltipPath}>
{filePath}
</Text>
<View style={styles.tooltipHintRow}>
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
<Text selectable={false} style={styles.tooltipHintText}>
click for side pane
{filePath ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<View style={styles.tooltipBody}>
<Text selectable={false} style={styles.tooltipPath}>
{filePath}
</Text>
<View style={styles.tooltipHintRow}>
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
<Text selectable={false} style={styles.tooltipHintText}>
click for side pane
</Text>
</View>
</View>
</View>
</TooltipContent>
</TooltipContent>
) : null}
</Tooltip>
);
}
@@ -0,0 +1,87 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
type MutableRefObject,
type ReactNode,
} from "react";
import React from "react";
import type { ToastApi } from "@/components/toast-host";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
export interface AssistantFileLinkDaemonClient {
getDirectorySuggestions: GetDirectorySuggestions;
}
export interface AssistantFileLinkResolverConfig {
client?: AssistantFileLinkDaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkResolverProviderProps extends AssistantFileLinkResolverConfig {
children: ReactNode;
}
export interface AssistantFileLinkResolverContextValue {
configRef: MutableRefObject<AssistantFileLinkResolverConfig>;
getDirectorySuggestions: GetDirectorySuggestions;
}
const AssistantFileLinkResolverContext =
createContext<AssistantFileLinkResolverContextValue | null>(null);
export function AssistantFileLinkResolverProvider({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
children,
}: AssistantFileLinkResolverProviderProps) {
const configRef = useRef<AssistantFileLinkResolverConfig>({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
});
configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast };
const getDirectorySuggestions = useCallback<GetDirectorySuggestions>(async (input) => {
const activeClient = configRef.current.client;
if (!activeClient) {
return { entries: [], error: null };
}
const result = await activeClient.getDirectorySuggestions(input);
return { entries: result.entries, error: result.error };
}, []);
const value = useMemo<AssistantFileLinkResolverContextValue>(
() => ({ configRef, getDirectorySuggestions }),
[getDirectorySuggestions],
);
return (
<AssistantFileLinkResolverContext.Provider value={value}>
{children}
</AssistantFileLinkResolverContext.Provider>
);
}
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
const context = useContext(AssistantFileLinkResolverContext);
if (!context) {
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
}
return context;
}
export type { AssistantFileLinkContext };
@@ -1,16 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import {
createAssistantFileLinkResolver,
classifyForResolution,
fetchDaemonResolution,
getAssistantFileLinkToken,
UnresolvedFileLinkError,
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult,
} from "./resolver";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
workspaceRoot: "/Users/test/project",
};
@@ -20,554 +19,201 @@ function resolvedSuggestions(
return { entries, error: null };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
getDirectorySuggestions: ReturnType<typeof vi.fn>;
searches: Array<{
query: string;
cwd: string;
matchMode: "suffix";
limit: number;
}>;
} {
const searches: Array<{
query: string;
cwd: string;
matchMode: "suffix";
limit: number;
}> = [];
const getDirectorySuggestions = vi.fn(
async (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => {
searches.push({
query: input.query,
cwd: input.cwd,
matchMode: input.matchMode,
limit: input.limit,
});
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
},
);
return { getDirectorySuggestions, searches };
}
interface DirectorySearch {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}
describe("classifyForResolution", () => {
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
class FakeWorkspaceFiles {
readonly searches: DirectorySearch[] = [];
readonly openedFiles: OpenedFile[] = [];
readonly unresolvedTokens: string[] = [];
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
createResolver() {
return createAssistantFileLinkResolver({
getDirectorySuggestions: this.getDirectorySuggestions,
openWorkspaceFile: this.openWorkspaceFile,
openExternalUrl: async () => {},
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
},
});
}
});
private getDirectorySuggestions = async (
search: DirectorySearch,
): Promise<DirectorySuggestionResult> => {
this.searches.push(search);
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
};
it("preserves line ranges on direct workspace files", () => {
const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT);
private openWorkspaceFile = (
target: InlinePathTarget,
disposition: OpenFileDisposition,
): void => {
this.openedFiles.push({ target, disposition });
};
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
},
});
});
private onUnresolvedFileCandidate = (token: string): void => {
this.unresolvedTokens.push(token);
};
}
describe("assistant file link resolver", () => {
it("dedupes in-flight prefetches and serves the click from cache", async () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => {
const result = classifyForResolution(
{ href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" },
CONTEXT,
);
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await Promise.all([
resolver.prefetch({ context: CONTEXT, source }),
resolver.prefetch({ context: CONTEXT, source }),
]);
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(suggestions).toHaveBeenCalledTimes(1);
expect(suggestions).toHaveBeenCalledWith({
query: "dumm.md",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/src/dumm.md",
lineStart: undefined,
expect(result).toEqual({
kind: "needsLookup",
ambiguousQuery: "file.ts",
token: "file.ts:12",
target: {
raw: "file.ts:12",
path: "/Users/test/project/file.ts",
lineStart: 12,
lineEnd: undefined,
},
"main",
});
});
it("keeps explicit external URLs external", () => {
const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: { kind: "external", url: "http://dumm.md" },
});
});
it("keeps auto-linkified normal domains external", () => {
const result = classifyForResolution(
{ href: "http://google.com", text: "google.com", markup: "linkify" },
CONTEXT,
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(result.opened).toBe(true);
expect(result).toEqual({
kind: "resolved",
value: { kind: "external", url: "http://google.com" },
});
});
it("click consumes an in-flight hover resolution", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const suggestions = vi.fn(() => deferred.promise);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
it("returns ignored for non-file-looking content", () => {
const result = classifyForResolution({ href: "" }, CONTEXT);
const prefetch = resolver.prefetch({ context: CONTEXT, source });
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await prefetch;
const result = await opened;
expect(suggestions).toHaveBeenCalledTimes(1);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
});
});
it("retries a click after hover prefetch fails to query suggestions", async () => {
const suggestions = vi
.fn()
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(openResult.opened).toBe(true);
});
it("does not cache unresolved candidates", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(first).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
expect(second.opened).toBe(true);
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
});
it("keys cache entries by server, workspace, and token", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await resolver.open({ context: CONTEXT, source, disposition: "main" });
await resolver.open({
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
source,
disposition: "main",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
{
raw: "dumm.md",
path: "/Users/test/other/two/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
});
it("does not apply stale async results after the active context changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
let isCurrent = true;
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(() => deferred.promise),
openWorkspaceFile,
openExternalUrl: vi.fn(),
isCurrentContext: () => isCurrent,
});
const opened = resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
isCurrent = false;
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
const result = await opened;
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(result.opened).toBe(false);
expect(result.kind).toBe("file");
});
it("opens direct workspace file links without querying suggestions", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
});
it("preserves direct workspace file line ranges", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx:33-40" },
disposition: "main",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
"main",
);
});
it("opens basename line refs when the daemon returns that exact filename", async () => {
const workspaceFiles = new FakeWorkspaceFiles({
describe("fetchDaemonResolution", () => {
it("resolves daemon suggestions into workspace file targets", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "file.ts:12",
text: "file.ts:12",
sourceType: "inline-code",
const result = await fetchDaemonResolution({
ambiguousQuery: "file.ts",
token: "file.ts:12",
target: {
raw: "file.ts:12",
path: "/Users/test/project/file.ts",
lineStart: 12,
lineEnd: undefined,
},
disposition: "main",
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(workspaceFiles.searches).toEqual([
expect(searches).toEqual([
{
query: "file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([
{
expect(result).toEqual({
raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
lineEnd: undefined,
});
});
it("throws a typed unresolved error when the daemon finds no match", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({});
await expect(
fetchDaemonResolution({
ambiguousQuery: "src/file.ts",
token: "src/file.ts",
target: {
raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
raw: "src/file.ts",
path: "/Users/test/project/src/file.ts",
lineStart: undefined,
lineEnd: undefined,
},
disposition: "main",
},
]);
expect(result.opened).toBe(true);
});
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
const workspaceFiles = new FakeWorkspaceFiles({});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "src/file.ts",
text: "src/file.ts",
sourceType: "inline-code",
},
disposition: "main",
});
expect(workspaceFiles.searches).toEqual([
{
query: "src/file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([]);
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "src/file.ts",
opened: false,
});
});
it("passes side open disposition to workspace file links", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "side",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"side",
);
});
it("keeps explicit external URLs external", async () => {
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md" },
disposition: "main",
});
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
expect(result).toEqual({
kind: "external",
url: "http://dumm.md",
opened: true,
});
});
it("keeps auto-linkified normal domains external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
expect(result).toEqual({
kind: "external",
url: "http://google.com",
opened: true,
});
});
it("keeps auto-linkified normal domain paths external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
expect(result).toEqual({
kind: "external",
url: "http://openai.com/path",
opened: true,
});
});
it("does not open unresolved linkified markdown filenames in the browser", async () => {
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const prefetchResult = await resolver.prefetch({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
disposition: "main",
});
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(openExternalUrl).not.toHaveBeenCalled();
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("keeps failed ambiguous resolution out of the browser", async () => {
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => {
throw new Error("daemon unavailable");
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
openWorkspaceFile: vi.fn(),
openExternalUrl,
onUnresolvedFileCandidate,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts"));
});
it("throws a typed unresolved error when the daemon throws", async () => {
const getDirectorySuggestions = vi.fn(async () => {
throw new Error("daemon unavailable");
});
await expect(
fetchDaemonResolution({
ambiguousQuery: "dumm.md",
token: "dumm.md",
target: {
raw: "dumm.md",
path: "/Users/test/project/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
});
});
describe("getAssistantFileLinkToken", () => {
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect(
getAssistantFileLinkToken({
+107 -198
View File
@@ -4,12 +4,6 @@ import {
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
export interface AssistantFileLinkContext {
serverId?: string;
workspaceRoot?: string;
}
export interface AssistantFileLinkSource {
href: string;
@@ -19,6 +13,10 @@ export interface AssistantFileLinkSource {
sourceType?: "inline-code";
}
export interface AssistantFileLinkContext {
workspaceRoot?: string;
}
export interface DirectorySuggestionEntry {
path: string;
kind: "file" | "directory";
@@ -29,152 +27,121 @@ export interface DirectorySuggestionResult {
error: string | null;
}
export interface AssistantFileLinkResolverDependencies {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
openExternalUrl: (url: string) => void | Promise<void>;
onUnresolvedFileCandidate?: (token: string) => void;
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
}
export interface AssistantFileLinkResolver {
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
}
export interface AssistantFileLinkPrefetchInput {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
}
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
disposition: OpenFileDisposition;
}
export type GetDirectorySuggestions = (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
export type ResolvedAssistantFileLink =
| { kind: "external"; url: string }
| { kind: "file"; target: InlinePathTarget }
| { kind: "ignored" };
export type AssistantFileLinkResolution =
| { kind: "resolved"; value: ResolvedAssistantFileLink }
| {
kind: "external";
url: string;
}
| {
kind: "file";
target: InlinePathTarget;
}
| {
kind: "unresolvedFileCandidate";
kind: "needsLookup";
ambiguousQuery: string;
token: string;
}
| {
kind: "ignored";
target: InlinePathTarget;
};
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
opened: boolean;
};
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
interface ParsedAssistantFileLinkInteraction {
export interface FetchDaemonResolutionInput {
ambiguousQuery: string;
token: string;
classification: AssistantFileLinkClassification;
target: InlinePathTarget;
workspaceRoot?: string;
getDirectorySuggestions: GetDirectorySuggestions;
}
export function createAssistantFileLinkResolver(
dependencies: AssistantFileLinkResolverDependencies,
): AssistantFileLinkResolver {
const cache = new Map<string, CachedAssistantFileLink>();
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
export class UnresolvedFileLinkError extends Error {
constructor(readonly token: string) {
super(`No file found for ${token}`);
this.name = "UnresolvedFileLinkError";
}
}
async function resolve(
input: AssistantFileLinkPrefetchInput,
): Promise<ResolvedAssistantFileLink> {
const parsed = parseInteraction(input);
if (!parsed) {
return { kind: "ignored" };
}
export async function fetchDaemonResolution({
ambiguousQuery,
token,
target,
workspaceRoot,
getDirectorySuggestions,
}: FetchDaemonResolutionInput): Promise<InlinePathTarget> {
const trimmedRoot = workspaceRoot?.trim();
if (!trimmedRoot) {
throw new UnresolvedFileLinkError(token);
}
if (parsed.classification.kind === "external") {
return { kind: "external", url: parsed.classification.raw };
}
let suggestions: DirectorySuggestionResult;
try {
suggestions = await getDirectorySuggestions({
query: ambiguousQuery,
cwd: trimmedRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
} catch {
throw new UnresolvedFileLinkError(token);
}
if (
parsed.classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
context: input.context,
source: input.source,
token: parsed.token,
target: parsed.classification.target,
})
) {
return { kind: "file", target: parsed.classification.target };
}
const key = getResolutionKey(input.context, parsed.token);
const cached = cache.get(key);
if (cached) {
return cached;
}
const active = inFlight.get(key);
if (active) {
return active;
}
const request = resolveAmbiguousCandidate({
context: input.context,
token: parsed.token,
target: parsed.classification.target,
getDirectorySuggestions: dependencies.getDirectorySuggestions,
})
.then((result) => {
if (result.kind === "file") {
cache.set(key, result);
}
inFlight.delete(key);
return result;
})
.catch((): CachedAssistantFileLink => {
inFlight.delete(key);
return { kind: "unresolvedFileCandidate", token: parsed.token };
});
inFlight.set(key, request);
return request;
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
throw new UnresolvedFileLinkError(token);
}
return {
prefetch(input) {
return resolve(input);
},
async open(input) {
const resolved = await resolve(input);
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
return { ...resolved, opened: false };
}
...target,
path: joinWorkspacePath(trimmedRoot, match.path),
};
}
if (resolved.kind === "file") {
dependencies.openWorkspaceFile(resolved.target, input.disposition);
return { ...resolved, opened: true };
}
export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
const token = getAssistantFileLinkToken(source).trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
if (resolved.kind === "external") {
await dependencies.openExternalUrl(resolved.url);
return { ...resolved, opened: true };
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
}
if (classification.kind === "external") {
return { kind: "resolved", value: { kind: "external", url: classification.raw } };
}
if (
classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
context,
source,
token,
target: classification.target,
})
) {
return { kind: "resolved", value: { kind: "file", target: classification.target } };
}
if (resolved.kind === "unresolvedFileCandidate") {
dependencies.onUnresolvedFileCandidate?.(resolved.token);
}
const workspaceRoot = context.workspaceRoot?.trim();
if (!workspaceRoot) {
return { kind: "resolved", value: { kind: "ignored" } };
}
return { ...resolved, opened: false };
},
return {
kind: "needsLookup",
ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot),
token,
target: classification.target,
};
}
@@ -189,59 +156,10 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri
return source.href;
}
function parseInteraction(
input: AssistantFileLinkPrefetchInput,
): ParsedAssistantFileLinkInteraction | null {
const token = getAssistantFileLinkToken(input.source).trim();
if (!token) {
return null;
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: input.context.workspaceRoot,
});
if (!classification) {
return null;
}
return { token, classification };
}
async function resolveAmbiguousCandidate(input: {
context: AssistantFileLinkContext;
token: string;
target: InlinePathTarget;
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
}): Promise<CachedAssistantFileLink> {
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
const suggestions = await input.getDirectorySuggestions({
query,
cwd: workspaceRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
return {
kind: "file",
target: {
...input.target,
path: joinWorkspacePath(workspaceRoot, match.path),
},
};
}
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
export function getAmbiguousSuggestionQuery(
target: InlinePathTarget,
workspaceRoot: string,
): string {
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = target.path.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
@@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
}
function shouldResolveDirectFileThroughSuggestions(input: {
export function shouldResolveDirectFileThroughSuggestions(input: {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
token: string;
@@ -285,23 +203,14 @@ function isAbsoluteInlineCodeToken(token: string): boolean {
);
}
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
}
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
return source.markup === "linkify" || source.sourceInfo === "auto";
}
function canApplyResult(
context: AssistantFileLinkContext,
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
): boolean {
return isCurrentContext ? isCurrentContext(context) : true;
}
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
return root ? `${root}/${child}` : child;
}
export type { AssistantFileLinkClassification };
@@ -0,0 +1,328 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { InlinePathTarget } from "./parse";
import { AssistantFileLinkResolverProvider } from "./provider";
import type { DirectorySuggestionResult } from "./resolver";
import { useFileLink } from "./use-file-link";
import type { OpenFileDisposition } from "@/workspace/file-open";
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(async () => {}),
}));
const SOURCE = {
href: "http://dumm.md",
text: "dumm.md",
markup: "linkify",
};
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
return { entries, error: null };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
interface TestClient {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
}
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function createWrapper(input: {
client: TestClient;
openedFiles: OpenedFile[];
toast?: {
show: ReturnType<typeof vi.fn>;
copied: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
}) {
const queryClient = createQueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
const openWorkspaceFile = useCallback(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
input.openedFiles.push({ target, disposition });
},
[],
);
return (
<QueryClientProvider client={queryClient}>
<AssistantFileLinkResolverProvider
client={input.client}
serverId="server-1"
workspaceRoot="/Users/test/project"
onOpenWorkspaceFile={openWorkspaceFile}
toast={input.toast}
>
{children}
</AssistantFileLinkResolverProvider>
</QueryClientProvider>
);
};
}
describe("useFileLink", () => {
it("returns the same object across no-op parent rerenders", () => {
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
const queryClient = createQueryClient();
const Provider = AssistantFileLinkResolverProvider as React.ComponentType<
Omit<React.ComponentProps<typeof AssistantFileLinkResolverProvider>, "children"> & {
children?: ReactNode;
}
>;
function ChurningProviderWrapper({ children }: { children: ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(
Provider,
{
client: { getDirectorySuggestions },
serverId: "server-1",
workspaceRoot: "/Users/test/project",
onOpenWorkspaceFile: () => {},
toast: { show: vi.fn(), copied: vi.fn(), error: vi.fn() },
},
children,
),
);
}
const { result, rerender } = renderHook(() => useFileLink({ ...SOURCE }), {
wrapper: ChurningProviderWrapper,
});
const first = result.current;
rerender();
expect(result.current).toBe(first);
expect(result.current.onHoverIn).toBe(first.onHoverIn);
expect(result.current.onPress).toBe(first.onPress);
expect(result.current.onAuxPress).toBe(first.onAuxPress);
expect(result.current.open).toBe(first.open);
});
it("does not cache unresolved lookups forever", async () => {
const getDirectorySuggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openedFiles: OpenedFile[] = [];
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
toast,
}),
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(toast.show).toHaveBeenCalledWith("No file found for dumm.md", {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toEqual([
{
target: {
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
disposition: "main",
},
]);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
});
it("click retries after hover prefetch fails", async () => {
const getDirectorySuggestions = vi
.fn()
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
});
act(() => {
result.current.onHoverIn();
});
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toHaveLength(1);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
});
it("dedupes two links pointing at the same source", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const getDirectorySuggestions = vi.fn(() => deferred.promise);
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(
() => ({
first: useFileLink(SOURCE),
second: useFileLink(SOURCE),
}),
{
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
},
);
act(() => {
result.current.first.onHoverIn();
result.current.second.onHoverIn();
});
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await waitFor(() => {
expect(result.current.first.target?.path).toBe("/Users/test/project/docs/dumm.md");
expect(result.current.second.target?.path).toBe("/Users/test/project/docs/dumm.md");
});
});
it("hover then click uses the prefetched result", async () => {
const getDirectorySuggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
);
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
});
act(() => {
result.current.onHoverIn();
});
await waitFor(() => {
expect(result.current.target?.path).toBe("/Users/test/project/docs/dumm.md");
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toHaveLength(1);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
it("does not open a stale result after the workspace changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const getDirectorySuggestions = vi.fn(() => deferred.promise);
const openedFiles: OpenedFile[] = [];
const queryClient = createQueryClient();
function Wrapper({ children }: { children: ReactNode }) {
const [workspaceRoot, setWorkspaceRoot] = useState("/Users/test/project");
const client = useMemo(() => ({ getDirectorySuggestions }), []);
const openWorkspaceFile = useCallback(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
openedFiles.push({ target, disposition });
},
[],
);
return (
<QueryClientProvider client={queryClient}>
<AssistantFileLinkResolverProvider
client={client}
serverId="server-1"
workspaceRoot={workspaceRoot}
onOpenWorkspaceFile={openWorkspaceFile}
>
<WorkspaceSwitchContext.Provider value={setWorkspaceRoot}>
{children}
</WorkspaceSwitchContext.Provider>
</AssistantFileLinkResolverProvider>
</QueryClientProvider>
);
}
const { result } = renderHook(
() => ({
link: useFileLink(SOURCE),
setWorkspaceRoot: React.useContext(WorkspaceSwitchContext),
}),
{ wrapper: Wrapper },
);
act(() => {
result.current.link.onPress();
});
act(() => {
result.current.setWorkspaceRoot("/Users/test/other");
});
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
expect(openedFiles).toEqual([]);
});
});
const WorkspaceSwitchContext = React.createContext<(workspaceRoot: string) => void>(() => {});
@@ -0,0 +1,347 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import type { InlinePathTarget } from "./parse";
import {
useAssistantFileLinkResolverContext,
type AssistantFileLinkResolverContextValue,
} from "./provider";
import {
classifyForResolution,
fetchDaemonResolution,
UnresolvedFileLinkError,
type AssistantFileLinkResolution,
type AssistantFileLinkSource,
} from "./resolver";
export interface UseFileLinkResult {
target: InlinePathTarget | null;
onHoverIn: () => void;
onPress: () => void;
onAuxPress: () => void;
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
}
export interface AssistantFileLinkActions {
open(source: AssistantFileLinkSource, disposition: OpenFileDisposition): void;
canOpen(source: AssistantFileLinkSource): boolean;
canResolveFile(source: AssistantFileLinkSource): boolean;
}
type AssistantFileLinkQueryKey = readonly [
"assistantFileLink",
string | null,
string | null,
string,
];
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const context = useAssistantFileLinkResolverContext();
const queryClient = useQueryClient();
const stableSource = useStableSource(source);
const activeConfig = context.configRef.current;
const workspaceRoot = activeConfig.workspaceRoot;
const serverId = activeConfig.serverId;
const resolution = useMemo(
() =>
classifyForResolution(stableSource, {
workspaceRoot,
}),
[stableSource, workspaceRoot],
);
const queryKey = useMemo(
() =>
resolution.kind === "needsLookup"
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
ambiguousQuery: resolution.ambiguousQuery,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
);
const query = useQuery({
queryKey,
queryFn: () => {
if (resolution.kind !== "needsLookup") {
throw new Error("Assistant file link lookup requested for a sync link.");
}
return fetchDaemonResolution({
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
target: resolution.target,
workspaceRoot,
getDirectorySuggestions: context.getDirectorySuggestions,
});
},
enabled: false,
retry: 0,
staleTime: Infinity,
});
const open = useStableEvent(
(nextSource: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
openAssistantFileLink({
source: nextSource,
disposition,
context,
queryClient,
});
},
);
const onHoverIn = useStableEvent(() => {
if (resolution.kind !== "needsLookup") {
return;
}
void queryClient.prefetchQuery({
queryKey,
queryFn: () =>
fetchDaemonResolution({
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
target: resolution.target,
workspaceRoot,
getDirectorySuggestions: context.getDirectorySuggestions,
}),
retry: 0,
staleTime: Infinity,
});
});
const onPress = useStableEvent(() => {
open(stableSource, "main");
});
const onAuxPress = useStableEvent(() => {
open(stableSource, "side");
});
const target = useMemo(() => {
if (resolution.kind === "resolved") {
return resolution.value.kind === "file" ? resolution.value.target : null;
}
return query.data ?? null;
}, [query.data, resolution]);
return useMemo(
() => ({ target, onHoverIn, onPress, onAuxPress, open }),
[target, onHoverIn, onPress, onAuxPress, open],
);
}
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
const context = useAssistantFileLinkResolverContext();
const actionLink = useFileLink(ACTION_LINK_SOURCE);
const open = useStableEvent(
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
actionLink.open(source, disposition);
},
);
const canOpen = useCallback(
(source: AssistantFileLinkSource) =>
canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot),
[context.configRef],
);
const canResolveFile = useCallback(
(source: AssistantFileLinkSource) =>
canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot),
[context.configRef],
);
return useMemo(() => ({ open, canOpen, canResolveFile }), [open, canOpen, canResolveFile]);
}
function openAssistantFileLink(input: {
source: AssistantFileLinkSource;
disposition: OpenFileDisposition;
context: AssistantFileLinkResolverContextValue;
queryClient: ReturnType<typeof useQueryClient>;
}): void {
const capturedConfig = input.context.configRef.current;
const capturedResolution = classifyForResolution(input.source, {
workspaceRoot: capturedConfig.workspaceRoot,
});
if (capturedResolution.kind === "resolved") {
void dispatchResolvedLink({
resolution: capturedResolution,
disposition: input.disposition,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
return;
}
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
ambiguousQuery: capturedResolution.ambiguousQuery,
});
const run = async () => {
try {
const target = await input.queryClient.fetchQuery({
queryKey: capturedQueryKey,
queryFn: () =>
fetchDaemonResolution({
ambiguousQuery: capturedResolution.ambiguousQuery,
token: capturedResolution.token,
target: capturedResolution.target,
workspaceRoot: capturedConfig.workspaceRoot,
getDirectorySuggestions: input.context.getDirectorySuggestions,
}),
retry: 0,
staleTime: Infinity,
});
await dispatchFileTarget({
target,
disposition: input.disposition,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
} catch (error) {
await dispatchUnresolvedError({
error,
fallbackToken: capturedResolution.token,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
}
};
void run();
}
function canOpenAssistantFileLink(
source: AssistantFileLinkSource,
workspaceRoot: string | undefined,
): boolean {
const resolution = classifyForResolution(source, { workspaceRoot });
return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored";
}
function canResolveAssistantFileLinkToFile(
source: AssistantFileLinkSource,
workspaceRoot: string | undefined,
): boolean {
const resolution = classifyForResolution(source, { workspaceRoot });
return resolution.kind === "needsLookup" || resolution.value.kind === "file";
}
function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource {
const { href, text, markup, sourceInfo, sourceType } = source;
return useMemo(
() => ({ href, text, markup, sourceInfo, sourceType }),
[href, text, markup, sourceInfo, sourceType],
);
}
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
ambiguousQuery: string;
}): AssistantFileLinkQueryKey {
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
input.ambiguousQuery,
];
}
async function dispatchResolvedLink(input: {
resolution: Extract<AssistantFileLinkResolution, { kind: "resolved" }>;
disposition: OpenFileDisposition;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const { value } = input.resolution;
if (value.kind === "file") {
await dispatchFileTarget({
target: value.target,
disposition: input.disposition,
capturedServerId: input.capturedServerId,
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
context: input.context,
});
return;
}
if (value.kind === "external") {
await dispatchExternalUrl({
url: value.url,
capturedServerId: input.capturedServerId,
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
context: input.context,
});
}
}
async function dispatchFileTarget(input: {
target: InlinePathTarget;
disposition: OpenFileDisposition;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
current.onOpenWorkspaceFile?.(input.target, input.disposition);
}
async function dispatchExternalUrl(input: {
url: string;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
await openExternalUrl(input.url);
}
async function dispatchUnresolvedError(input: {
error: unknown;
fallbackToken: string;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
const token =
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
current.toast?.show(`No file found for ${token}`, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
}
const ACTION_LINK_SOURCE: AssistantFileLinkSource = {
href: "",
};
@@ -1,90 +0,0 @@
import { useMemo, useRef } from "react";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ToastApi } from "@/components/toast-host";
import {
createAssistantFileLinkResolver,
type AssistantFileLinkContext,
type AssistantFileLinkOpenInput,
type AssistantFileLinkPrefetchInput,
} from "./resolver";
import type { InlinePathTarget } from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
export interface UseAssistantFileLinkResolverOptions {
client?: DaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkActions {
prefetch(input: Omit<AssistantFileLinkPrefetchInput, "context">): void;
open(input: Omit<AssistantFileLinkOpenInput, "context">): void;
}
export function useAssistantFileLinkResolver({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
const context: AssistantFileLinkContext = useMemo(
() => ({
serverId,
workspaceRoot,
}),
[serverId, workspaceRoot],
);
const latestContextRef = useRef(context);
latestContextRef.current = context;
const resolver = useMemo(
() =>
createAssistantFileLinkResolver({
async getDirectorySuggestions(input) {
if (!client) {
return { entries: [], error: null };
}
const result = await client.getDirectorySuggestions(input);
return {
entries: result.entries,
error: result.error,
};
},
openWorkspaceFile(target, disposition) {
onOpenWorkspaceFile?.(target, disposition);
},
openExternalUrl,
onUnresolvedFileCandidate(token) {
toast?.show(`No file found for ${token}`, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
},
isCurrentContext(candidate) {
const current = latestContextRef.current;
return (
current.serverId === candidate.serverId &&
current.workspaceRoot === candidate.workspaceRoot
);
},
}),
[client, onOpenWorkspaceFile, toast],
);
return useMemo(
() => ({
prefetch(input) {
void resolver.prefetch({ ...input, context });
},
open(input) {
void resolver.open({ ...input, context });
},
}),
[context, resolver],
);
}
@@ -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(
+15 -59
View File
@@ -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}
@@ -240,6 +240,15 @@ const CATALOG_DATA = [
installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli",
command: ["kilo", "acp"],
},
{
id: "kiro",
title: "Kiro CLI",
description: "Amazon's AI coding agent with native ACP support",
version: "manual",
iconId: null,
installLink: "https://kiro.dev/docs/cli/acp/",
command: ["kiro-cli", "acp"],
},
{
id: "kimi",
title: "Kimi CLI",
@@ -26,7 +26,7 @@ describe("ACP provider catalog", () => {
});
it("bundles SVG icons for catalog entries that declare an icon", () => {
const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.id !== "hermes");
const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.iconSvg !== null);
expect(entriesWithIcons.length).toBeGreaterThan(0);
for (const entry of entriesWithIcons) {
@@ -39,6 +39,7 @@ describe("ACP provider catalog", () => {
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
});
+36 -128
View File
@@ -1,30 +1,31 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Pressable, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHostRuntimeSnapshot,
useHostMutations,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { confirmDialog } from "@/utils/confirm-dialog";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { SettingsSection } from "@/screens/settings/settings-section";
import { ProvidersSection } from "@/screens/settings/providers-section";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
const RESTART_CONFIRMATION_MESSAGE =
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
@@ -68,7 +69,6 @@ function formatDaemonVersionBadge(version: string | null): string | null {
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
}
const RENAME_HOST_HEADER: SheetHeader = { title: "Rename host" };
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
@@ -178,64 +178,23 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
const { theme } = useUnistyles();
const { renameHost } = useHostMutations();
const [isEditing, setIsEditing] = useState(false);
const [draftLabel, setDraftLabel] = useState(host.label ?? "");
const [isSaving, setIsSaving] = useState(false);
const inputRef = useRef<TextInput>(null);
useEffect(() => {
setDraftLabel(host.label ?? "");
}, [host.serverId, host.label]);
useEffect(() => {
if (isEditing) {
const timeout = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(timeout);
}
return undefined;
}, [isEditing]);
const handleSave = useCallback(async () => {
const nextLabel = draftLabel.trim();
if (!nextLabel) {
Alert.alert("Label required", "Enter a label for this host.");
return;
}
if (isSaving) return;
if (nextLabel === host.label.trim()) {
setIsEditing(false);
return;
}
try {
setIsSaving(true);
const handleSubmit = useCallback(
async (value: string) => {
const nextLabel = value.trim();
if (nextLabel === host.label.trim()) return;
await renameHost(host.serverId, nextLabel);
setIsEditing(false);
} catch (error) {
console.error("[HostPage] Failed to rename host", error);
Alert.alert("Error", "Unable to save host");
} finally {
setIsSaving(false);
}
}, [draftLabel, host.label, host.serverId, isSaving, renameHost]);
},
[host.label, host.serverId, renameHost],
);
const handleCancel = useCallback(() => {
if (isSaving) return;
setDraftLabel(host.label ?? "");
setIsEditing(false);
}, [host.label, isSaving]);
const handleStartEdit = useCallback(() => {
setDraftLabel(host.label ?? "");
setIsEditing(true);
}, [host.label]);
const handleSavePress = useCallback(() => {
void handleSave();
}, [handleSave]);
const openEditor = useCallback(() => setIsEditing(true), []);
const closeEditor = useCallback(() => setIsEditing(false), []);
return (
<>
<Pressable
onPress={handleStartEdit}
onPress={openEditor}
hitSlop={8}
style={styles.identityEditButton}
accessibilityRole="button"
@@ -245,48 +204,16 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<AdaptiveModalSheet
<AdaptiveRenameModal
visible={isEditing}
onClose={handleCancel}
header={RENAME_HOST_HEADER}
title="Rename host"
initialValue={host.label}
placeholder="My Host"
submitLabel="Save"
onClose={closeEditor}
onSubmit={handleSubmit}
testID="host-page-rename-modal"
>
<View style={styles.renameBody}>
<TextInput
ref={inputRef}
value={draftLabel}
onChangeText={setDraftLabel}
placeholder="My Host"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
onSubmitEditing={handleSavePress}
style={styles.renameInput}
testID="host-page-label-input"
/>
<View style={styles.renameActions}>
<Button
variant="secondary"
size="sm"
style={FLEX_1_STYLE}
onPress={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="sm"
style={FLEX_1_STYLE}
onPress={handleSavePress}
disabled={isSaving}
testID="host-page-label-save"
>
{isSaving ? "Saving..." : "Save"}
</Button>
</View>
</View>
</AdaptiveModalSheet>
/>
</>
);
}
@@ -836,25 +763,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
renameBody: {
gap: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
renameInput: {
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,
},
renameActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
}));
const FLEX_1_STYLE = { flex: 1 };
@@ -0,0 +1,125 @@
import { useCallback, useState } from "react";
import { type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
interface RenamingTabState {
kind: "terminal" | "agent";
id: string;
currentTitle: string;
}
interface UseWorkspaceTabRenameInput {
client: DaemonClient | null;
normalizedServerId: string;
queryClient: QueryClient;
terminalsData: ListTerminalsResponse["payload"] | undefined;
terminalsQueryKey: readonly unknown[];
}
interface UseWorkspaceTabRenameResult {
renamingTab: RenamingTabState | null;
handleRenameTab: (tab: WorkspaceTabDescriptor) => void;
handleRenameModalSubmit: (nextTitle: string) => Promise<void>;
handleRenameModalClose: () => void;
}
export function useWorkspaceTabRename(
input: UseWorkspaceTabRenameInput,
): UseWorkspaceTabRenameResult {
const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input;
const [renamingTab, setRenamingTab] = useState<RenamingTabState | null>(null);
const handleRenameTab = useCallback(
(tab: WorkspaceTabDescriptor) => {
if (tab.target.kind === "terminal") {
const { terminalId } = tab.target;
const terminal = terminalsData?.terminals.find((entry) => entry.id === terminalId) ?? null;
const currentTitle = terminal?.title ?? terminal?.name ?? "";
setRenamingTab({ kind: "terminal", id: terminalId, currentTitle });
return;
}
if (tab.target.kind === "agent") {
const { agentId } = tab.target;
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const currentTitle = agent?.title ?? "";
setRenamingTab({ kind: "agent", id: agentId, currentTitle });
}
},
[normalizedServerId, terminalsData],
);
const handleRenameModalSubmit = useCallback(
async (nextTitle: string) => {
if (!renamingTab) return;
if (!client) {
throw new Error("Host is not connected");
}
const trimmed = nextTitle.trim();
if (renamingTab.kind === "terminal") {
const result = await client.renameTerminal({
terminalId: renamingTab.id,
title: trimmed,
});
if (!result.success) {
throw new Error(result.error ?? "Failed to rename terminal");
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
return;
}
await client.updateAgent(renamingTab.id, { name: trimmed });
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", normalizedServerId],
});
void queryClient.invalidateQueries({
queryKey: ["allAgents", normalizedServerId],
});
},
[client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey],
);
const handleRenameModalClose = useCallback(() => {
setRenamingTab(null);
}, []);
return {
renamingTab,
handleRenameTab,
handleRenameModalSubmit,
handleRenameModalClose,
};
}
export interface WorkspaceTabRenameModalProps {
renamingTab: RenamingTabState | null;
onClose: () => void;
onSubmit: (nextTitle: string) => Promise<void>;
}
export function WorkspaceTabRenameModal({
renamingTab,
onClose,
onSubmit,
}: WorkspaceTabRenameModalProps) {
const title = renamingTab?.kind === "terminal" ? "Rename terminal" : "Rename agent";
const initialValue = renamingTab?.currentTitle ?? "";
const testID = renamingTab
? `workspace-tab-rename-modal-${renamingTab.kind}-${renamingTab.id}`
: undefined;
return (
<AdaptiveRenameModal
visible={renamingTab !== null}
title={title}
initialValue={initialValue}
submitLabel="Rename"
maxLength={200}
onClose={onClose}
onSubmit={onSubmit}
testID={testID}
/>
);
}
@@ -22,6 +22,7 @@ import {
ArrowRightToLine,
Columns2,
Copy,
Pencil,
RotateCw,
Rows2,
Globe,
@@ -71,6 +72,7 @@ const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
const ThemedCopyX = withUnistyles(CopyX);
const ThemedPencil = withUnistyles(Pencil);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
@@ -101,6 +103,8 @@ function TabContextMenuItem({
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
case "copy-x":
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
case "pencil":
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
case "x":
return <ThemedX size={16} uniProps={mutedColorMapping} />;
default:
@@ -150,6 +154,7 @@ interface WorkspaceDesktopTabsRowProps {
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) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
@@ -467,6 +472,7 @@ export function WorkspaceDesktopTabsRow({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -599,6 +605,7 @@ export function WorkspaceDesktopTabsRow({
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -630,6 +637,7 @@ export function WorkspaceDesktopTabsRow({
onCopyResumeCommand,
onNavigateTab,
onReloadAgent,
onRenameTab,
setHoveredCloseTabKey,
setHoveredTabKey,
tabDropPreviewIndex,
@@ -791,6 +799,7 @@ function ResolvedDesktopTabChip({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -815,6 +824,7 @@ function ResolvedDesktopTabChip({
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) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
@@ -838,6 +848,7 @@ function ResolvedDesktopTabChip({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsToLeft,
onCloseTabsToRight,
@@ -853,6 +864,7 @@ function ResolvedDesktopTabChip({
onCopyAgentId,
onCopyResumeCommand,
onReloadAgent,
onRenameTab,
tabCount,
],
);
@@ -11,7 +11,7 @@ import {
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
@@ -26,6 +26,7 @@ import {
Globe,
Import as ImportIcon,
PanelRight,
Pencil,
RotateCw,
Settings,
SquarePen,
@@ -109,6 +110,10 @@ import {
WorkspaceTabOptionRow,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import {
useWorkspaceTabRename,
WorkspaceTabRenameModal,
} from "@/screens/workspace/use-workspace-tab-rename";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
@@ -181,6 +186,7 @@ const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
const ThemedCopyX = withUnistyles(CopyX);
const ThemedPencil = withUnistyles(Pencil);
const ThemedX = withUnistyles(X);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
@@ -297,6 +303,7 @@ interface MobileWorkspaceTabSwitcherProps {
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
@@ -436,6 +443,8 @@ function MobileTabDropdownMenuItem({
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
case "copy-x":
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
case "pencil":
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
case "x":
return <ThemedX size={16} uniProps={mutedColorMapping} />;
default:
@@ -473,6 +482,7 @@ function MobileWorkspaceTabOption({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
@@ -489,6 +499,7 @@ function MobileWorkspaceTabOption({
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
@@ -504,6 +515,7 @@ function MobileWorkspaceTabOption({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsBefore: onCloseTabsAbove,
onCloseTabsAfter: onCloseTabsBelow,
@@ -558,6 +570,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
@@ -611,6 +624,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
@@ -627,6 +641,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
@@ -1532,6 +1547,7 @@ function WorkspaceScreenContent({
openWorkspaceTabFocused,
toast,
});
const queryClient = useQueryClient();
const {
createMutation: createTerminalMutation,
createTerminal,
@@ -1543,6 +1559,7 @@ function WorkspaceScreenContent({
liveTerminalIds,
pendingCreateInput: pendingTerminalCreateInput,
query: terminalsQuery,
queryKey: terminalsQueryKey,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
@@ -2125,6 +2142,14 @@ function WorkspaceScreenContent({
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
useWorkspaceTabRename({
client,
normalizedServerId,
queryClient,
terminalsData: terminalsQuery.data,
terminalsQueryKey,
});
const tabByKey = useMemo(() => {
const map = new Map<string, WorkspaceTabDescriptor>();
@@ -3152,6 +3177,7 @@ function WorkspaceScreenContent({
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
onCloseTabsToRight={handleCloseTabsToRightInPane}
onCloseOtherTabs={handleCloseOtherTabsInPane}
@@ -3186,6 +3212,7 @@ function WorkspaceScreenContent({
handleCopyResumeCommand,
handleCopyAgentId,
handleReloadAgent,
handleRenameTab,
handleCloseTabsToLeftInPane,
handleCloseTabsToRightInPane,
handleCloseOtherTabsInPane,
@@ -3268,6 +3295,7 @@ function WorkspaceScreenContent({
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
@@ -3289,6 +3317,7 @@ function WorkspaceScreenContent({
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
@@ -3337,6 +3366,11 @@ function WorkspaceScreenContent({
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={renamingTab}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</WorkspaceFocusProvider>
)
@@ -16,6 +16,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
const onCopyResumeCommand = vi.fn();
const onCopyAgentId = vi.fn();
const onReloadAgent = vi.fn();
const onRenameTab = vi.fn();
const onCloseTab = vi.fn();
const onCloseTabsBefore = vi.fn();
const onCloseTabsAfter = vi.fn();
@@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsBefore,
onCloseTabsAfter,
@@ -39,6 +41,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
"Copy resume command",
"Copy agent id",
"Rename",
"Close to the left",
"Close to the right",
"Close other tabs",
@@ -57,6 +60,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onRenameTab: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -66,6 +70,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
"Copy resume command",
"Copy agent id",
"Rename",
"Close tabs above",
"Close tabs below",
"Close other tabs",
@@ -74,7 +79,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
]);
});
it("omits agent copy actions for non-agent tabs", () => {
it("omits agent copy actions and rename for draft tabs", () => {
const entries = buildWorkspaceTabMenuEntries({
surface: "mobile",
tab: {
@@ -89,6 +94,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onRenameTab: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -101,6 +107,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe(
false,
);
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Rename")).toBe(false);
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
});
@@ -114,6 +121,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onRenameTab: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -128,4 +136,128 @@ describe("buildWorkspaceTabMenuEntries", () => {
}),
);
});
it("invokes onRenameTab when the rename entry is selected for agent tabs", () => {
const onRenameTab = vi.fn();
const tab = createAgentTab();
const entries = buildWorkspaceTabMenuEntries({
surface: "desktop",
tab,
index: 0,
tabCount: 1,
menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onRenameTab,
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
onCloseOtherTabs: vi.fn(),
});
const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename");
if (!renameEntry || renameEntry.kind !== "item") {
throw new Error("Rename entry missing");
}
renameEntry.onSelect();
expect(onRenameTab).toHaveBeenCalledWith(tab);
});
it("includes rename as the first entry for terminal tabs", () => {
const onRenameTab = vi.fn();
const terminalTab: WorkspaceTabDescriptor = {
key: "terminal_abc",
tabId: "terminal_abc",
kind: "terminal",
target: { kind: "terminal", terminalId: "terminal-abc" },
};
const entries = buildWorkspaceTabMenuEntries({
surface: "desktop",
tab: terminalTab,
index: 0,
tabCount: 1,
menuTestIDBase: "workspace-tab-context-terminal_abc",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onRenameTab,
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
onCloseOtherTabs: vi.fn(),
});
const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label);
expect(labels[0]).toBe("Rename");
expect(labels).not.toContain("Copy resume command");
expect(labels).not.toContain("Copy agent id");
expect(labels).not.toContain("Reload agent");
const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename");
if (!renameEntry || renameEntry.kind !== "item") {
throw new Error("Rename entry missing");
}
renameEntry.onSelect();
expect(onRenameTab).toHaveBeenCalledWith(terminalTab);
});
it("uses the same rename entry shape for agent and terminal tabs", () => {
const terminalTab: WorkspaceTabDescriptor = {
key: "terminal_abc",
tabId: "terminal_abc",
kind: "terminal",
target: { kind: "terminal", terminalId: "terminal-abc" },
};
const menuTestIDBase = "workspace-tab-context";
const sharedInput = {
surface: "desktop" as const,
index: 0,
tabCount: 1,
menuTestIDBase,
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onRenameTab: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
onCloseOtherTabs: vi.fn(),
};
const agentEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: createAgentTab() });
const terminalEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: terminalTab });
const agentRename = agentEntries.find(
(entry) => entry.kind === "item" && entry.key === "rename",
);
const terminalRename = terminalEntries.find(
(entry) => entry.kind === "item" && entry.key === "rename",
);
if (!agentRename || agentRename.kind !== "item") throw new Error("Agent rename missing");
if (!terminalRename || terminalRename.kind !== "item")
throw new Error("Terminal rename missing");
expect({
key: agentRename.key,
label: agentRename.label,
icon: agentRename.icon,
testID: agentRename.testID,
}).toEqual({
key: terminalRename.key,
label: terminalRename.label,
icon: terminalRename.icon,
testID: terminalRename.testID,
});
const agentSeparator = agentEntries
.slice(agentEntries.indexOf(agentRename) + 1)
.find((entry) => entry.kind === "separator");
const terminalSeparator = terminalEntries
.slice(terminalEntries.indexOf(terminalRename) + 1)
.find((entry) => entry.kind === "separator");
expect(agentSeparator?.key).toBe("rename-separator");
expect(terminalSeparator?.key).toBe("rename-separator");
});
});
@@ -8,7 +8,14 @@ export type WorkspaceTabMenuEntry =
kind: "item";
key: string;
label: string;
icon?: "copy" | "rotate-cw" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
icon?:
| "copy"
| "rotate-cw"
| "arrow-left-to-line"
| "arrow-right-to-line"
| "copy-x"
| "pencil"
| "x";
hint?: string;
tooltip?: string;
disabled?: boolean;
@@ -30,6 +37,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
@@ -43,6 +51,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
@@ -102,6 +111,7 @@ export function buildWorkspaceTabMenuEntries(
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsBefore,
onCloseTabsAfter,
@@ -135,9 +145,22 @@ export function buildWorkspaceTabMenuEntries(
void onCopyAgentId(agentId);
},
});
}
if (tab.target.kind === "agent" || tab.target.kind === "terminal") {
entries.push({
kind: "item",
key: "rename",
label: "Rename",
icon: "pencil",
testID: `${menuTestIDBase}-rename`,
onSelect: () => {
onRenameTab(tab);
},
});
entries.push({
kind: "separator",
key: "copy-separator",
key: "rename-separator",
});
}
@@ -217,6 +240,7 @@ export function buildWorkspaceDesktopTabActions(
onCopyResumeCommand: input.onCopyResumeCommand,
onCopyAgentId: input.onCopyAgentId,
onReloadAgent: input.onReloadAgent,
onRenameTab: input.onRenameTab,
onCloseTab: input.onCloseTab,
onCloseTabsBefore: input.onCloseTabsToLeft,
onCloseTabsAfter: input.onCloseTabsToRight,
@@ -15,6 +15,7 @@ import {
} from "@server/shared/terminal-input-mode";
import {
type PendingTerminalModifiers,
isAppleHandheldPlatform,
isTerminalModifierDomKey,
mergeTerminalModifiers,
normalizeDomTerminalKey,
@@ -84,6 +85,14 @@ const isMac =
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
const isAppleHandheld =
typeof navigator !== "undefined" &&
isAppleHandheldPlatform({
userAgent: navigator.userAgent,
platform: (navigator as Navigator & { platform?: string }).platform,
maxTouchPoints: navigator.maxTouchPoints,
});
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
@@ -365,6 +374,7 @@ export class TerminalEmulatorRuntime {
metaKey: event.metaKey,
pendingModifiers: this.pendingModifiers,
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
isAppleHandheld,
})
) {
return true;
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
hasPendingTerminalModifiers,
isAppleHandheldPlatform,
isTerminalModifierDomKey,
mapTerminalDataToKey,
mergeTerminalModifiers,
@@ -10,6 +11,13 @@ import {
shouldInterceptDomTerminalKey,
} from "./terminal-keys";
const IPAD_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15";
const MAC_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
const IPHONE_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148";
describe("terminal key helpers", () => {
it("normalizes supported DOM keys", () => {
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
@@ -157,6 +165,140 @@ describe("terminal key helpers", () => {
).toBe(false);
});
it("intercepts plain Ctrl+C on iPad so xterm's keyCode-13 quirk never reaches the PTY (#1049)", () => {
// See COMPAT(xterm-ipad-ctrl-c) in terminal-keys.ts.
expect(
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
isAppleHandheld: true,
}),
).toBe(true);
// Uppercase variant in case Caps Lock is on.
expect(
shouldInterceptDomTerminalKey({
key: "C",
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
isAppleHandheld: true,
}),
).toBe(true);
});
it("does not intercept other Ctrl+letter combos on iPad (xterm handles them correctly)", () => {
for (const key of ["b", "d", "z", "a", "r", "l"]) {
expect(
shouldInterceptDomTerminalKey({
key,
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
isAppleHandheld: true,
}),
).toBe(false);
}
});
it("does not intercept Ctrl+C on real macOS / Windows / Linux", () => {
expect(
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
isAppleHandheld: false,
}),
).toBe(false);
});
it("does not intercept Cmd+C on iPad (Cmd-based shortcuts stay with the OS)", () => {
expect(
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: true,
pendingModifiers: { ctrl: false, shift: false, alt: false },
isAppleHandheld: true,
}),
).toBe(false);
});
it("does not intercept Ctrl+Shift+C / Ctrl+Alt+C on iPad", () => {
// Only bare Ctrl+C is affected by the WebKit quirk; modified variants stay with xterm.
expect(
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: true,
shiftKey: true,
altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
isAppleHandheld: true,
}),
).toBe(false);
expect(
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: true,
shiftKey: false,
altKey: true,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
isAppleHandheld: true,
}),
).toBe(false);
});
it("detects iPad masquerading as macOS via maxTouchPoints", () => {
expect(
isAppleHandheldPlatform({ userAgent: IPAD_UA, platform: "MacIntel", maxTouchPoints: 5 }),
).toBe(true);
});
it("detects iPhone/iPod by UA", () => {
expect(
isAppleHandheldPlatform({ userAgent: IPHONE_UA, platform: "iPhone", maxTouchPoints: 5 }),
).toBe(true);
});
it("does not flag real macOS desktop as a handheld", () => {
expect(
isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 0 }),
).toBe(false);
});
it("does not flag macOS when maxTouchPoints == 1 (some trackpad contexts)", () => {
expect(
isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 1 }),
).toBe(false);
});
it("tolerates null/undefined navigator-style inputs", () => {
expect(isAppleHandheldPlatform({ userAgent: null, platform: null, maxTouchPoints: null })).toBe(
false,
);
expect(
isAppleHandheldPlatform({
userAgent: undefined,
platform: undefined,
maxTouchPoints: undefined,
}),
).toBe(false);
});
it("detects pending modifier state", () => {
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
+35
View File
@@ -82,6 +82,27 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
return modifiers.ctrl || modifiers.shift || modifiers.alt;
}
interface AppleHandheldDetectionInput {
userAgent: string | null | undefined;
platform: string | null | undefined;
maxTouchPoints: number | null | undefined;
}
// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish
// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS.
export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean {
const userAgent = input.userAgent ?? "";
const platform = input.platform ?? "";
const touchPoints = input.maxTouchPoints ?? 0;
if (/iPad|iPhone|iPod/.test(userAgent)) {
return true;
}
if (/Mac/i.test(platform) && touchPoints > 1) {
return true;
}
return false;
}
export function shouldInterceptDomTerminalKey(args: {
key: string;
ctrlKey: boolean;
@@ -90,6 +111,7 @@ export function shouldInterceptDomTerminalKey(args: {
metaKey: boolean;
pendingModifiers: PendingTerminalModifiers;
enhancedInputActive?: boolean;
isAppleHandheld?: boolean;
}): boolean {
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
return true;
@@ -97,6 +119,19 @@ export function shouldInterceptDomTerminalKey(args: {
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
return Boolean(args.enhancedInputActive);
}
// COMPAT(xterm-ipad-ctrl-c): WebKit sends keyCode=13 for hardware-kbd Ctrl+C on iPad, so
// xterm.js emits \r instead of \x03. Upstream: xtermjs/xterm.js#5721, targeting xterm.js 7.0.0.
// Drop this block and the isAppleHandheld plumbing once @xterm/xterm is bumped past it.
if (
args.isAppleHandheld &&
args.ctrlKey &&
!args.metaKey &&
!args.altKey &&
!args.shiftKey &&
(args.key === "c" || args.key === "C")
) {
return true;
}
return false;
}