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

View File

@@ -31,6 +31,7 @@ import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
import { useHostRuntimeSession } from '@/runtime/host-runtime' import { useHostRuntimeSession } from '@/runtime/host-runtime'
import { import {
deleteAttachments, deleteAttachments,
persistAttachmentFromBlob,
persistAttachmentFromFileUri, persistAttachmentFromFileUri,
} from '@/attachments/service' } from '@/attachments/service'
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls' import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
@@ -390,16 +391,24 @@ export function AgentInputArea({
async function handlePickImage() { async function handlePickImage() {
const result = await pickImages() const result = await pickImages()
if (!result?.assets?.length) { if (!result?.length) {
return return
} }
const newImages = await Promise.all( 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({ return await persistAttachmentFromFileUri({
uri: asset.uri, uri: pickedImage.source.uri,
mimeType: asset.mimeType || 'image/jpeg', mimeType: pickedImage.mimeType || 'image/jpeg',
fileName: asset.fileName ?? null, fileName: pickedImage.fileName ?? null,
}) })
}) })
) )

View File

@@ -18,6 +18,9 @@ import {
useCallback, useCallback,
createContext, createContext,
useContext, useContext,
isValidElement,
Children,
cloneElement,
} from "react"; } from "react";
import type { ReactNode, ComponentType } from "react"; import type { ReactNode, ComponentType } from "react";
import Markdown, { MarkdownIt } from "react-native-markdown-display"; import Markdown, { MarkdownIt } from "react-native-markdown-display";
@@ -433,20 +436,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
containerSpacing: { containerSpacing: {
marginBottom: theme.spacing[4], 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 // Used in custom markdownRules for path chip styling
pathChip: { pathChip: {
backgroundColor: theme.colors.surface2, 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( function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>, markdownParser: ReturnType<typeof MarkdownIt>,
content: string content: string
@@ -776,7 +794,7 @@ export const AssistantMessage = memo(function AssistantMessage({
node: any, node: any,
_children: ReactNode[], _children: ReactNode[],
_parent: any, _parent: any,
_styles: any, styles: any,
inheritedStyles: any = {} inheritedStyles: any = {}
) => { ) => {
const content = node.content ?? ""; const content = node.content ?? "";
@@ -803,30 +821,21 @@ export const AssistantMessage = memo(function AssistantMessage({
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content); const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
if (inlineCodeLinkUrl) { if (inlineCodeLinkUrl) {
return ( return (
<Text <MarkdownLink
key={node.key} key={node.key}
accessibilityRole="link" href={inlineCodeLinkUrl}
onPress={() => { style={[inheritedStyles, styles.code_inline, styles.link]}
handleLinkPress(inlineCodeLinkUrl); onPress={handleLinkPress}
}}
style={[
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
assistantMessageStylesheet.markdownCodeInlineLink,
]}
> >
{content} {content}
</Text> </MarkdownLink>
); );
} }
return ( return (
<Text <Text
key={node.key} key={node.key}
style={[ style={[inheritedStyles, styles.code_inline]}
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
]}
> >
{content} {content}
</Text> </Text>
@@ -877,6 +886,25 @@ export const AssistantMessage = memo(function AssistantMessage({
</View> </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]); }, [handleLinkPress, markdownParser, onInlinePathPress]);

View File

@@ -124,6 +124,7 @@ interface WorkspaceRowInnerProps {
archiveStatus?: 'idle' | 'pending' | 'success' archiveStatus?: 'idle' | 'pending' | 'success'
archivePendingLabel?: string archivePendingLabel?: string
onArchive?: () => void onArchive?: () => void
onCopyBranchName?: () => void
onCopyPath?: () => void onCopyPath?: () => void
} }
@@ -612,6 +613,7 @@ function WorkspaceRowInner({
archiveStatus = 'idle', archiveStatus = 'idle',
archivePendingLabel, archivePendingLabel,
onArchive, onArchive,
onCopyBranchName,
onCopyPath, onCopyPath,
}: WorkspaceRowInnerProps) { }: WorkspaceRowInnerProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
@@ -696,6 +698,15 @@ function WorkspaceRowInner({
Copy path Copy path
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : 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 <DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`} testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />} leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
@@ -733,6 +744,7 @@ function WorkspaceRowWithMenu({
drag, drag,
isDragging, isDragging,
dragHandleProps, dragHandleProps,
canCopyBranchName,
}: { }: {
workspace: SidebarWorkspaceEntry workspace: SidebarWorkspaceEntry
selected: boolean selected: boolean
@@ -742,6 +754,7 @@ function WorkspaceRowWithMenu({
drag: () => void drag: () => void
isDragging: boolean isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps dragHandleProps?: DraggableListDragHandleProps
canCopyBranchName: boolean
}) { }) {
const toast = useToast() const toast = useToast()
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
@@ -827,6 +840,11 @@ function WorkspaceRowWithMenu({
toast.copied('Path copied') toast.copied('Path copied')
}, [toast, workspace.workspaceId]) }, [toast, workspace.workspaceId])
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name)
toast.copied('Branch name copied')
}, [toast, workspace.name])
return ( return (
<WorkspaceRowInner <WorkspaceRowInner
workspace={workspace} workspace={workspace}
@@ -843,6 +861,7 @@ function WorkspaceRowWithMenu({
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'} archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'} archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace} onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath} onCopyPath={handleCopyPath}
/> />
) )
@@ -1008,6 +1027,7 @@ function WorkspaceRow({
drag, drag,
isDragging, isDragging,
dragHandleProps, dragHandleProps,
canCopyBranchName,
}: { }: {
workspace: SidebarWorkspaceEntry workspace: SidebarWorkspaceEntry
selected: boolean selected: boolean
@@ -1017,6 +1037,7 @@ function WorkspaceRow({
drag: () => void drag: () => void
isDragging: boolean isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps dragHandleProps?: DraggableListDragHandleProps
canCopyBranchName: boolean
}) { }) {
return ( return (
<WorkspaceRowWithMenu <WorkspaceRowWithMenu
@@ -1028,6 +1049,7 @@ function WorkspaceRow({
drag={drag} drag={drag}
isDragging={isDragging} isDragging={isDragging}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
canCopyBranchName={canCopyBranchName}
/> />
) )
} }
@@ -1095,6 +1117,7 @@ function ProjectBlock({
selected={isSelected} selected={isSelected}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null} shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges} showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === 'git'}
onPress={() => { onPress={() => {
if (!serverId) { if (!serverId) {
return return

View File

@@ -9,6 +9,8 @@ import {
useState, useState,
type PropsWithChildren, type PropsWithChildren,
type ReactElement, type ReactElement,
type Ref,
type MutableRefObject,
} from "react"; } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -182,6 +184,16 @@ function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | nu
return null; 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({ export function ContextMenu({
open, open,
defaultOpen, defaultOpen,
@@ -231,6 +243,7 @@ export function ContextMenuTrigger({
enabledOnMobile = false, enabledOnMobile = false,
enabledOnWeb = true, enabledOnWeb = true,
longPressDelayMs, longPressDelayMs,
triggerRef,
...props ...props
}: PropsWithChildren< }: PropsWithChildren<
Omit<PressableProps, "style"> & { Omit<PressableProps, "style"> & {
@@ -239,6 +252,7 @@ export function ContextMenuTrigger({
enabledOnMobile?: boolean; enabledOnMobile?: boolean;
enabledOnWeb?: boolean; enabledOnWeb?: boolean;
longPressDelayMs?: number; longPressDelayMs?: number;
triggerRef?: Ref<View | null>;
} }
>): ReactElement { >): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger"); const ctx = useContextMenuContext("ContextMenuTrigger");
@@ -268,10 +282,18 @@ export function ContextMenuTrigger({
[ctx, disabled, shouldEnableOnThisPlatform] [ctx, disabled, shouldEnableOnThisPlatform]
); );
const handleRef = useCallback(
(node: View | null) => {
assignRef(ctx.triggerRef, node);
assignRef(triggerRef, node);
},
[ctx.triggerRef, triggerRef]
);
return ( return (
<Pressable <Pressable
{...props} {...props}
ref={ctx.triggerRef} ref={handleRef}
collapsable={false} collapsable={false}
disabled={disabled} disabled={disabled}
delayLongPress={longPressDelayMs} delayLongPress={longPressDelayMs}

View File

@@ -1,5 +1,8 @@
import { import {
Children,
cloneElement,
createContext, createContext,
isValidElement,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
@@ -8,6 +11,7 @@ import {
useState, useState,
type PropsWithChildren, type PropsWithChildren,
type ReactElement, type ReactElement,
type Ref,
} from "react"; } from "react";
import { import {
Dimensions, Dimensions,
@@ -53,6 +57,26 @@ function useTooltipContext(componentName: string): TooltipContextValue {
return ctx; 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({ function useControllableOpenState({
open, open,
defaultOpen, defaultOpen,
@@ -224,8 +248,15 @@ export function TooltipTrigger({
onFocus, onFocus,
onBlur, onBlur,
onPress, onPress,
asChild = false,
triggerRefProp = "ref",
...props ...props
}: PropsWithChildren<PressableProps>): ReactElement { }: PropsWithChildren<
PressableProps & {
asChild?: boolean;
triggerRefProp?: string;
}
>): ReactElement {
const ctx = useTooltipContext("TooltipTrigger"); const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -276,37 +307,86 @@ export function TooltipTrigger({
[onHoverOut, close] [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 ( return (
<Pressable <Pressable
{...props} {...triggerProps}
ref={ctx.triggerRef} ref={ctx.triggerRef}
collapsable={false} 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} {children}
</Pressable> </Pressable>

View File

@@ -0,0 +1,107 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const tauriState = vi.hoisted(() => ({
api: null as any,
}));
vi.mock("@/utils/tauri", () => ({
getTauri: () => tauriState.api,
}));
import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
} from "./image-attachment-picker";
describe("image-attachment-picker", () => {
beforeEach(() => {
tauriState.api = null;
});
it("normalizes a picked File into a blob source", async () => {
const file = new File(["hello"], "picked.png", { type: "image/png" });
const result = await normalizePickedImageAssets([
{
uri: "blob:test",
mimeType: "image/png",
fileName: null,
file,
},
]);
expect(result).toHaveLength(1);
expect(result[0]?.source.kind).toBe("blob");
expect(result[0]?.fileName).toBe("picked.png");
expect(result[0]?.mimeType).toBe("image/png");
});
it("keeps filesystem picker results as file uris", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///tmp/picked.png",
mimeType: "image/png",
fileName: "picked.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///tmp/picked.png" },
mimeType: "image/png",
fileName: "picked.png",
},
]);
});
it("converts data urls into blob sources when no file path exists", async () => {
const result = await normalizePickedImageAssets([
{
uri: "data:image/png;base64,AAEC",
mimeType: "image/png",
fileName: "inline.png",
},
]);
expect(result).toHaveLength(1);
expect(result[0]?.source.kind).toBe("blob");
expect(result[0]?.fileName).toBe("inline.png");
expect(result[0]?.mimeType).toBe("image/png");
});
it("uses the tauri dialog api when available", async () => {
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
tauriState.api = {
dialog: { open },
};
const result = await openImagePathsWithTauriDialog();
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
multiple: true,
directory: false,
title: "Attach images",
})
);
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
});
it("falls back to core invoke for the tauri dialog plugin", async () => {
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
tauriState.api = {
core: { invoke },
};
const result = await openImagePathsWithTauriDialog();
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
options: expect.objectContaining({
multiple: true,
directory: false,
title: "Attach images",
}),
});
expect(result).toEqual(["/tmp/one.png"]);
});
});

View File

@@ -0,0 +1,110 @@
import { getTauri } from "@/utils/tauri";
export type PickedImageSource =
| { kind: "file_uri"; uri: string }
| { kind: "blob"; blob: Blob };
export interface PickedImageAttachmentInput {
source: PickedImageSource;
mimeType?: string | null;
fileName?: string | null;
}
export interface ExpoImagePickerAssetLike {
uri: string;
mimeType?: string | null;
fileName?: string | null;
file?: File | null;
}
const IMAGE_FILE_EXTENSIONS = [
"png",
"jpg",
"jpeg",
"gif",
"webp",
"avif",
"heic",
"heif",
"tiff",
"bmp",
"svg",
];
function isAbsoluteWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(value);
}
function shouldTreatAsFileUri(uri: string): boolean {
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
}
async function blobFromUri(uri: string): Promise<Blob> {
const response = await fetch(uri);
if (!response.ok) {
throw new Error(`Failed to read picked image from '${uri}'.`);
}
return await response.blob();
}
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[]
): Promise<PickedImageAttachmentInput[]> {
return await Promise.all(
assets.map(async (asset) => {
if (asset.file instanceof Blob) {
return {
source: { kind: "blob", blob: asset.file },
mimeType: asset.mimeType ?? asset.file.type ?? null,
fileName: asset.fileName ?? asset.file.name ?? null,
};
}
if (shouldTreatAsFileUri(asset.uri)) {
return {
source: { kind: "file_uri", uri: asset.uri },
mimeType: asset.mimeType ?? null,
fileName: asset.fileName ?? null,
};
}
return {
source: { kind: "blob", blob: await blobFromUri(asset.uri) },
mimeType: asset.mimeType ?? null,
fileName: asset.fileName ?? null,
};
})
);
}
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
if (!selection) {
return [];
}
return Array.isArray(selection) ? selection : [selection];
}
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
const tauri = getTauri();
const options = {
directory: false,
multiple: true,
filters: [{ name: "Images", extensions: IMAGE_FILE_EXTENSIONS }],
title: "Attach images",
};
const dialogOpen = tauri?.dialog?.open;
if (typeof dialogOpen === "function") {
return normalizeTauriDialogSelection(await dialogOpen(options));
}
const invoke = tauri?.core?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri dialog API is not available.");
}
const result = await invoke("plugin:dialog|open", { options });
return normalizeTauriDialogSelection(
Array.isArray(result) || typeof result === "string" || result === null ? result : null
);
}

View File

@@ -1,11 +1,16 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { Alert } from "react-native"; import { Alert } from "react-native";
import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { isTauriEnvironment } from "@/utils/tauri";
type ImagePickerSuccessResult = ImagePicker.ImagePickerSuccessResult; import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker";
interface UseImageAttachmentPickerResult { interface UseImageAttachmentPickerResult {
pickImages: () => Promise<ImagePickerSuccessResult | null>; pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
} }
export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
@@ -37,6 +42,18 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true; isPickingRef.current = true;
try { try {
if (Platform.OS === "web" && isTauriEnvironment()) {
const selectedPaths = await openImagePathsWithTauriDialog();
if (selectedPaths.length === 0) {
return null;
}
return selectedPaths.map((path) => ({
source: { kind: "file_uri" as const, uri: path },
mimeType: null,
fileName: null,
}));
}
const hasPermission = await ensurePermission(); const hasPermission = await ensurePermission();
if (!hasPermission) { if (!hasPermission) {
return null; return null;
@@ -44,7 +61,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
const pendingResult = await ImagePicker.getPendingResultAsync(); const pendingResult = await ImagePicker.getPendingResultAsync();
if (pendingResult && "canceled" in pendingResult && !pendingResult.canceled) { if (pendingResult && "canceled" in pendingResult && !pendingResult.canceled) {
return pendingResult as ImagePickerSuccessResult; return await normalizePickedImageAssets(pendingResult.assets);
} }
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
@@ -57,7 +74,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
return null; return null;
} }
return result; return await normalizePickedImageAssets(result.assets);
} catch (error) { } catch (error) {
console.error("[ImageAttachmentPicker] Failed to pick image:", error); console.error("[ImageAttachmentPicker] Failed to pick image:", error);
Alert.alert("Error", "Failed to select image"); Alert.alert("Error", "Failed to select image");

View File

@@ -156,8 +156,6 @@ export function WorkspaceDesktopTabsRow({
const layoutItem = layout.items[index] ?? null; const layoutItem = layout.items[index] ?? null;
const resolvedTabWidth = layoutItem?.width ?? 150; const resolvedTabWidth = layoutItem?.width ?? 150;
const showLabel = layoutItem?.showLabel ?? true; const showLabel = layoutItem?.showLabel ?? true;
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent }); const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
const tooltipLabel = const tooltipLabel =
tab.kind === "agent" && tab.titleState === "loading" tab.kind === "agent" && tab.titleState === "loading"
@@ -172,11 +170,7 @@ export function WorkspaceDesktopTabsRow({
return ( return (
<ContextMenu key={tab.key}> <ContextMenu key={tab.key}>
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger asChild triggerRefProp="triggerRef">
testID={`workspace-tab-tooltip-${tab.key}`}
accessibilityRole="none"
style={styles.tabTooltipTrigger}
>
<ContextMenuTrigger <ContextMenuTrigger
testID={`workspace-tab-${tab.key}`} testID={`workspace-tab-${tab.key}`}
enabledOnMobile={false} enabledOnMobile={false}
@@ -228,9 +222,11 @@ export function WorkspaceDesktopTabsRow({
isActive && styles.tabLabelActive, isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton, shouldShowCloseButton && styles.tabLabelWithCloseButton,
]} ]}
selectable={false}
numberOfLines={1} numberOfLines={1}
ellipsizeMode="tail"
> >
{renderedLabel} {tab.label}
</Text> </Text>
) )
) : null} ) : null}
@@ -411,9 +407,6 @@ const styles = StyleSheet.create((theme) => ({
paddingRight: theme.spacing[2], paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
tabTooltipTrigger: {
flexShrink: 0,
},
tab: { tab: {
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
@@ -421,6 +414,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
userSelect: "none",
}, },
tabHandle: { tabHandle: {
flexDirection: "row", flexDirection: "row",
@@ -428,6 +422,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1], gap: theme.spacing[1],
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
userSelect: "none",
}, },
tabIcon: { tabIcon: {
flexShrink: 0, flexShrink: 0,
@@ -444,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
userSelect: "none",
}, },
tabLabelSkeleton: { tabLabelSkeleton: {
width: 96, width: 96,

View File

@@ -344,6 +344,10 @@ function WorkspaceScreenContent({
const isWorkspaceHeaderLoading = workspaceHeader === null; const isWorkspaceHeaderLoading = workspaceHeader === null;
const isGitCheckout = checkoutQuery.data?.isGit ?? false; const isGitCheckout = checkoutQuery.data?.isGit ?? false;
const currentBranchName =
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
? trimNonEmpty(checkoutQuery.data.currentBranch)
: null;
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore( const desktopFileExplorerOpen = usePanelStore(
(state) => state.desktop.fileExplorerOpen (state) => state.desktop.fileExplorerOpen
@@ -1025,6 +1029,20 @@ function WorkspaceScreenContent({
} }
}, [normalizedWorkspaceId, toast]); }, [normalizedWorkspaceId, toast]);
const handleCopyBranchName = useCallback(async () => {
if (!currentBranchName) {
toast.error("Branch name not available");
return;
}
try {
await Clipboard.setStringAsync(currentBranchName);
toast.copied("Branch name");
} catch {
toast.error("Copy failed");
}
}, [currentBranchName, toast]);
const handleBulkCloseTabs = useCallback( const handleBulkCloseTabs = useCallback(
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => { async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
const { tabsToClose, title, logLabel } = input; const { tabsToClose, title, logLabel } = input;
@@ -1389,6 +1407,20 @@ function WorkspaceScreenContent({
> >
Copy workspace path Copy workspace path
</DropdownMenuItem> </DropdownMenuItem>
{currentBranchName ? (
<DropdownMenuItem
testID="workspace-header-copy-branch-name"
leading={
<Copy
size={16}
color={theme.colors.foregroundMuted}
/>
}
onSelect={handleCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</View> </View>

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { createMarkdownStyles } from "./markdown-styles";
import { darkTheme } from "./theme";
describe("createMarkdownStyles", () => {
it("applies shrink-and-wrap constraints to long markdown text and links", () => {
const styles = createMarkdownStyles(darkTheme);
expect(styles.body).toMatchObject({
flexShrink: 1,
minWidth: 0,
width: "100%",
});
expect(styles.paragraph).toMatchObject({
flexShrink: 1,
minWidth: 0,
width: "100%",
flexWrap: "wrap",
});
expect(styles.text).toMatchObject({
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere",
});
expect(styles.link).toMatchObject({
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere",
});
expect(styles.blocklink).toMatchObject({
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere",
});
});
});

View File

@@ -18,10 +18,16 @@ export function createMarkdownStyles(theme: Theme) {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
lineHeight: 22, lineHeight: 22,
flexShrink: 1,
minWidth: 0,
width: "100%" as const,
}, },
text: { text: {
color: theme.colors.foreground, color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
}, },
paragraph: { paragraph: {
@@ -31,6 +37,9 @@ export function createMarkdownStyles(theme: Theme) {
flexDirection: "row" as const, flexDirection: "row" as const,
alignItems: "flex-start" as const, alignItems: "flex-start" as const,
justifyContent: "flex-start" as const, justifyContent: "flex-start" as const,
flexShrink: 1,
minWidth: 0,
width: "100%" as const,
}, },
// ========================================================================= // =========================================================================
@@ -117,13 +126,19 @@ export function createMarkdownStyles(theme: Theme) {
}, },
link: { link: {
color: theme.colors.primary, color: theme.colors.accentBright,
textDecorationLine: "underline" as const, textDecorationLine: "none" as const,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
}, },
blocklink: { blocklink: {
color: theme.colors.primary, color: theme.colors.accentBright,
textDecorationLine: "underline" as const, textDecorationLine: "none" as const,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
}, },
// ========================================================================= // =========================================================================
@@ -135,7 +150,8 @@ export function createMarkdownStyles(theme: Theme) {
color: theme.colors.foreground, color: theme.colors.foreground,
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
paddingVertical: 2, paddingVertical: 2,
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.md,
borderWidth: 0,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },

View File

@@ -123,6 +123,7 @@ const lightSemanticColors = {
// Brand // Brand
accent: "#20744A", accent: "#20744A",
accentBright: "#239956",
accentForeground: "#ffffff", accentForeground: "#ffffff",
// Semantic // Semantic
@@ -192,6 +193,7 @@ const darkSemanticColors = {
// Brand // Brand
accent: "#20744A", accent: "#20744A",
accentBright: "#7ccba0",
accentForeground: "#ffffff", accentForeground: "#ffffff",
// Semantic // Semantic

View File

@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const tauriMock = vi.hoisted(() => {
let listenerCleanup: (() => void) | null = null;
const connect = vi.fn(async () => {
let listenerActive = false;
return {
addListener: vi.fn(() => {
listenerActive = true;
listenerCleanup = vi.fn(() => {
if (!listenerActive) {
throw new TypeError(
"undefined is not an object (evaluating 'listeners[eventId].handlerId')"
);
}
listenerActive = false;
});
return listenerCleanup;
}),
send: vi.fn(async () => undefined),
disconnect: vi.fn(async () => {
listenerCleanup?.();
}),
};
});
return {
connect,
getListenerCleanup: () => listenerCleanup,
};
});
vi.mock("./tauri", () => ({
getTauri: () => ({
websocket: {
connect: tauriMock.connect,
},
}),
}));
describe("tauri-daemon-transport", () => {
beforeEach(() => {
tauriMock.connect.mockClear();
});
it("does not unregister the websocket listener twice during close", async () => {
const mod = await import("./tauri-daemon-transport");
const transportFactory = mod.createTauriWebSocketTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
await Promise.resolve();
expect(() => transport.close()).not.toThrow();
expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1);
});
});

View File

@@ -229,14 +229,10 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
return { return {
...transport, ...transport,
close: (code?: number, reason?: string) => { close: (code?: number, reason?: string) => {
wsListenerCleanup = null;
transport.close(code, reason); transport.close(code, reason);
disposed = true; disposed = true;
try { ws = null;
wsListenerCleanup?.();
} catch {
// no-op
}
wsListenerCleanup = null;
}, },
}; };
}; };

View File

@@ -7,8 +7,22 @@ export interface TauriDialogAskOptions {
kind?: "info" | "warning" | "error"; kind?: "info" | "warning" | "error";
} }
export interface TauriDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
export interface TauriDialogApi { export interface TauriDialogApi {
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>; ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
open?: (
options?: TauriDialogOpenOptions
) => Promise<string | string[] | null>;
} }
export interface TauriCoreApi { export interface TauriCoreApi {