feat(app): improve image picker, markdown rendering, and UI interactions

This commit is contained in:
Mohamed Boudra
2026-03-12 23:29:26 +07:00
parent 4b5cb5a3e2
commit 9190d86148
16 changed files with 642 additions and 91 deletions
@@ -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,
})
})
)
+58 -30
View File
@@ -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}
+108 -28
View File
@@ -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>