feat(app): improve image picker, markdown rendering, and UI interactions
This commit is contained in:
@@ -31,6 +31,7 @@ import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
||||
import {
|
||||
deleteAttachments,
|
||||
persistAttachmentFromBlob,
|
||||
persistAttachmentFromFileUri,
|
||||
} from '@/attachments/service'
|
||||
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
|
||||
@@ -390,16 +391,24 @@ export function AgentInputArea({
|
||||
|
||||
async function handlePickImage() {
|
||||
const result = await pickImages()
|
||||
if (!result?.assets?.length) {
|
||||
if (!result?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const newImages = await Promise.all(
|
||||
result.assets.map(async (asset) => {
|
||||
result.map(async (pickedImage) => {
|
||||
if (pickedImage.source.kind === 'blob') {
|
||||
return await persistAttachmentFromBlob({
|
||||
blob: pickedImage.source.blob,
|
||||
mimeType: pickedImage.mimeType || 'image/jpeg',
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
return await persistAttachmentFromFileUri({
|
||||
uri: asset.uri,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
fileName: asset.fileName ?? null,
|
||||
uri: pickedImage.source.uri,
|
||||
mimeType: pickedImage.mimeType || 'image/jpeg',
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
isValidElement,
|
||||
Children,
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
@@ -433,20 +436,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
// Used in custom markdownRules for inline code styling
|
||||
markdownCodeInline: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
},
|
||||
markdownCodeInlineLink: {
|
||||
color: theme.colors.primary,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
// Used in custom markdownRules for path chip styling
|
||||
pathChip: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -463,6 +452,35 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function MarkdownLink({
|
||||
href,
|
||||
style,
|
||||
onPress,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
style: any;
|
||||
onPress: (url: string) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const hoverProps =
|
||||
Platform.OS === "web"
|
||||
? { onHoverIn: () => setHovered(true), onHoverOut: () => setHovered(false) }
|
||||
: {};
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={() => onPress(href)}
|
||||
{...hoverProps}
|
||||
>
|
||||
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||
content: string
|
||||
@@ -776,7 +794,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
_styles: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => {
|
||||
const content = node.content ?? "";
|
||||
@@ -803,30 +821,21 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
||||
if (inlineCodeLinkUrl) {
|
||||
return (
|
||||
<Text
|
||||
<MarkdownLink
|
||||
key={node.key}
|
||||
accessibilityRole="link"
|
||||
onPress={() => {
|
||||
handleLinkPress(inlineCodeLinkUrl);
|
||||
}}
|
||||
style={[
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
assistantMessageStylesheet.markdownCodeInlineLink,
|
||||
]}
|
||||
href={inlineCodeLinkUrl}
|
||||
style={[inheritedStyles, styles.code_inline, styles.link]}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</MarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
]}
|
||||
style={[inheritedStyles, styles.code_inline]}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
@@ -877,6 +886,25 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
link: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
) => (
|
||||
<MarkdownLink
|
||||
key={node.key}
|
||||
href={node.attributes?.href ?? ""}
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement(child)
|
||||
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
|
||||
: child
|
||||
)}
|
||||
</MarkdownLink>
|
||||
),
|
||||
};
|
||||
}, [handleLinkPress, markdownParser, onInlinePathPress]);
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ interface WorkspaceRowInnerProps {
|
||||
archiveStatus?: 'idle' | 'pending' | 'success'
|
||||
archivePendingLabel?: string
|
||||
onArchive?: () => void
|
||||
onCopyBranchName?: () => void
|
||||
onCopyPath?: () => void
|
||||
}
|
||||
|
||||
@@ -612,6 +613,7 @@ function WorkspaceRowInner({
|
||||
archiveStatus = 'idle',
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const { theme } = useUnistyles()
|
||||
@@ -696,6 +698,15 @@ function WorkspaceRowInner({
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
@@ -733,6 +744,7 @@ function WorkspaceRowWithMenu({
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
@@ -742,6 +754,7 @@ function WorkspaceRowWithMenu({
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
canCopyBranchName: boolean
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||
@@ -827,6 +840,11 @@ function WorkspaceRowWithMenu({
|
||||
toast.copied('Path copied')
|
||||
}, [toast, workspace.workspaceId])
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name)
|
||||
toast.copied('Branch name copied')
|
||||
}, [toast, workspace.name])
|
||||
|
||||
return (
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
@@ -843,6 +861,7 @@ function WorkspaceRowWithMenu({
|
||||
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
||||
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
)
|
||||
@@ -1008,6 +1027,7 @@ function WorkspaceRow({
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
@@ -1017,6 +1037,7 @@ function WorkspaceRow({
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
canCopyBranchName: boolean
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
@@ -1028,6 +1049,7 @@ function WorkspaceRow({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1095,6 +1117,7 @@ function ProjectBlock({
|
||||
selected={isSelected}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={project.projectKind === 'git'}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -182,6 +184,16 @@ function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as MutableRefObject<T>).current = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
open,
|
||||
defaultOpen,
|
||||
@@ -231,6 +243,7 @@ export function ContextMenuTrigger({
|
||||
enabledOnMobile = false,
|
||||
enabledOnWeb = true,
|
||||
longPressDelayMs,
|
||||
triggerRef,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
@@ -239,6 +252,7 @@ export function ContextMenuTrigger({
|
||||
enabledOnMobile?: boolean;
|
||||
enabledOnWeb?: boolean;
|
||||
longPressDelayMs?: number;
|
||||
triggerRef?: Ref<View | null>;
|
||||
}
|
||||
>): ReactElement {
|
||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||
@@ -268,10 +282,18 @@ export function ContextMenuTrigger({
|
||||
[ctx, disabled, shouldEnableOnThisPlatform]
|
||||
);
|
||||
|
||||
const handleRef = useCallback(
|
||||
(node: View | null) => {
|
||||
assignRef(ctx.triggerRef, node);
|
||||
assignRef(triggerRef, node);
|
||||
},
|
||||
[ctx.triggerRef, triggerRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
ref={ctx.triggerRef}
|
||||
ref={handleRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
delayLongPress={longPressDelayMs}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
@@ -8,6 +11,7 @@ import {
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
@@ -53,6 +57,26 @@ function useTooltipContext(componentName: string): TooltipContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function composeEventHandlers<E>(
|
||||
original?: (event: E) => void,
|
||||
injected?: (event: E) => void
|
||||
): (event: E) => void {
|
||||
return (event: E) => {
|
||||
original?.(event);
|
||||
injected?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as { current: T }).current = value;
|
||||
}
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
@@ -224,8 +248,15 @@ export function TooltipTrigger({
|
||||
onFocus,
|
||||
onBlur,
|
||||
onPress,
|
||||
asChild = false,
|
||||
triggerRefProp = "ref",
|
||||
...props
|
||||
}: PropsWithChildren<PressableProps>): ReactElement {
|
||||
}: PropsWithChildren<
|
||||
PressableProps & {
|
||||
asChild?: boolean;
|
||||
triggerRefProp?: string;
|
||||
}
|
||||
>): ReactElement {
|
||||
const ctx = useTooltipContext("TooltipTrigger");
|
||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -276,37 +307,86 @@ export function TooltipTrigger({
|
||||
[onHoverOut, close]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: any) => {
|
||||
onFocus?.(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
},
|
||||
[clearOpenTimer, ctx, disabled, onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: any) => {
|
||||
onBlur?.(e);
|
||||
close();
|
||||
},
|
||||
[close, onBlur]
|
||||
);
|
||||
|
||||
const handlePress = useCallback(
|
||||
(e: any) => {
|
||||
onPress?.(e);
|
||||
close();
|
||||
},
|
||||
[close, onPress]
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
...props,
|
||||
disabled,
|
||||
onHoverIn: handleHoverIn,
|
||||
onHoverOut: handleHoverOut,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onPress: handlePress,
|
||||
...(Platform.OS === "web"
|
||||
? ({
|
||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||
onPointerEnter: handleHoverIn,
|
||||
onPointerLeave: handleHoverOut,
|
||||
onMouseEnter: handleHoverIn,
|
||||
onMouseLeave: handleHoverOut,
|
||||
} as any)
|
||||
: null),
|
||||
};
|
||||
|
||||
if (asChild) {
|
||||
const child = Children.only(children);
|
||||
if (!isValidElement(child)) {
|
||||
throw new Error("TooltipTrigger with asChild expects a single React element child");
|
||||
}
|
||||
|
||||
const childProps = child.props as Record<string, any>;
|
||||
const mergedProps = {
|
||||
...childProps,
|
||||
...triggerProps,
|
||||
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
|
||||
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
|
||||
onBlur: composeEventHandlers(childProps.onBlur, handleBlur),
|
||||
onPress: composeEventHandlers(childProps.onPress, handlePress),
|
||||
onPointerEnter: composeEventHandlers(childProps.onPointerEnter, handleHoverIn),
|
||||
onPointerLeave: composeEventHandlers(childProps.onPointerLeave, handleHoverOut),
|
||||
onMouseEnter: composeEventHandlers(childProps.onMouseEnter, handleHoverIn),
|
||||
onMouseLeave: composeEventHandlers(childProps.onMouseLeave, handleHoverOut),
|
||||
} as Record<string, any>;
|
||||
|
||||
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
|
||||
mergedProps[triggerRefProp] = (node: View | null) => {
|
||||
assignRef(existingRefProp, node);
|
||||
assignRef(ctx.triggerRef, node);
|
||||
};
|
||||
|
||||
return cloneElement(child, mergedProps);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
{...triggerProps}
|
||||
ref={ctx.triggerRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
onFocus={(e) => {
|
||||
onFocus?.(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur?.(e);
|
||||
close();
|
||||
}}
|
||||
onPress={(e) => {
|
||||
onPress?.(e);
|
||||
close();
|
||||
}}
|
||||
{...(Platform.OS === "web"
|
||||
? ({
|
||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||
onPointerEnter: handleHoverIn,
|
||||
onPointerLeave: handleHoverOut,
|
||||
onMouseEnter: handleHoverIn,
|
||||
onMouseLeave: handleHoverOut,
|
||||
} as any)
|
||||
: null)}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
|
||||
Reference in New Issue
Block a user