diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index bd1f64c47..abec6dd01 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -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, }) }) ) diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 3f83d9772..6698e6181 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ( + onPress(href)} + {...hoverProps} + > + + {children} + + + ); +} + function getInlineCodeAutoLinkUrl( markdownParser: ReturnType, 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 ( - { - handleLinkPress(inlineCodeLinkUrl); - }} - style={[ - inheritedStyles, - assistantMessageStylesheet.markdownCodeInline, - assistantMessageStylesheet.markdownCodeInlineLink, - ]} + href={inlineCodeLinkUrl} + style={[inheritedStyles, styles.code_inline, styles.link]} + onPress={handleLinkPress} > {content} - + ); } return ( {content} @@ -877,6 +886,25 @@ export const AssistantMessage = memo(function AssistantMessage({ ); }, + link: ( + node: any, + children: ReactNode[], + _parent: any, + styles: any, + ) => ( + + {Children.map(children, (child) => + isValidElement(child) + ? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any) + : child + )} + + ), }; }, [handleLinkPress, markdownParser, onInlinePathPress]); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index f4a095de5..ae2f18a23 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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 ) : null} + {onCopyBranchName ? ( + } + onSelect={onCopyBranchName} + > + Copy branch name + + ) : null} } @@ -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 ( ) @@ -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 ( ) } @@ -1095,6 +1117,7 @@ function ProjectBlock({ selected={isSelected} shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null} showShortcutBadge={showShortcutBadges} + canCopyBranchName={project.projectKind === 'git'} onPress={() => { if (!serverId) { return diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index df174b6b7..8c0f4df79 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -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(ref: Ref | undefined, value: T): void { + if (typeof ref === "function") { + ref(value); + return; + } + if (ref && typeof ref === "object") { + (ref as MutableRefObject).current = value; + } +} + export function ContextMenu({ open, defaultOpen, @@ -231,6 +243,7 @@ export function ContextMenuTrigger({ enabledOnMobile = false, enabledOnWeb = true, longPressDelayMs, + triggerRef, ...props }: PropsWithChildren< Omit & { @@ -239,6 +252,7 @@ export function ContextMenuTrigger({ enabledOnMobile?: boolean; enabledOnWeb?: boolean; longPressDelayMs?: number; + triggerRef?: Ref; } >): 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 ( ( + original?: (event: E) => void, + injected?: (event: E) => void +): (event: E) => void { + return (event: E) => { + original?.(event); + injected?.(event); + }; +} + +function assignRef(ref: Ref | 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): ReactElement { +}: PropsWithChildren< + PressableProps & { + asChild?: boolean; + triggerRefProp?: string; + } +>): ReactElement { const ctx = useTooltipContext("TooltipTrigger"); const openTimerRef = useRef | 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; + 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; + + const existingRefProp = childProps[triggerRefProp] as Ref | undefined; + mergedProps[triggerRefProp] = (node: View | null) => { + assignRef(existingRefProp, node); + assignRef(ctx.triggerRef, node); + }; + + return cloneElement(child, mergedProps); + } + return ( { - 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} diff --git a/packages/app/src/hooks/image-attachment-picker.test.ts b/packages/app/src/hooks/image-attachment-picker.test.ts new file mode 100644 index 000000000..78a8ce0b1 --- /dev/null +++ b/packages/app/src/hooks/image-attachment-picker.test.ts @@ -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"]); + }); +}); diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts new file mode 100644 index 000000000..a46e261de --- /dev/null +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -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 { + 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 { + 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 { + 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 + ); +} diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index c79d55e64..89a344aa6 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -1,11 +1,16 @@ import { useCallback, useRef } from "react"; import { Alert } from "react-native"; +import { Platform } from "react-native"; import * as ImagePicker from "expo-image-picker"; - -type ImagePickerSuccessResult = ImagePicker.ImagePickerSuccessResult; +import { isTauriEnvironment } from "@/utils/tauri"; +import { + normalizePickedImageAssets, + openImagePathsWithTauriDialog, + type PickedImageAttachmentInput, +} from "@/hooks/image-attachment-picker"; interface UseImageAttachmentPickerResult { - pickImages: () => Promise; + pickImages: () => Promise; } export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { @@ -37,6 +42,18 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { isPickingRef.current = true; 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(); if (!hasPermission) { return null; @@ -44,7 +61,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { const pendingResult = await ImagePicker.getPendingResultAsync(); if (pendingResult && "canceled" in pendingResult && !pendingResult.canceled) { - return pendingResult as ImagePickerSuccessResult; + return await normalizePickedImageAssets(pendingResult.assets); } const result = await ImagePicker.launchImageLibraryAsync({ @@ -57,7 +74,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { return null; } - return result; + return await normalizePickedImageAssets(result.assets); } catch (error) { console.error("[ImageAttachmentPicker] Failed to pick image:", error); Alert.alert("Error", "Failed to select image"); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index bba5d691d..15ea705c7 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -156,8 +156,6 @@ export function WorkspaceDesktopTabsRow({ const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; 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 tooltipLabel = tab.kind === "agent" && tab.titleState === "loading" @@ -172,11 +170,7 @@ export function WorkspaceDesktopTabsRow({ return ( - + - {renderedLabel} + {tab.label} ) ) : null} @@ -411,9 +407,6 @@ const styles = StyleSheet.create((theme) => ({ paddingRight: theme.spacing[2], paddingVertical: theme.spacing[1], }, - tabTooltipTrigger: { - flexShrink: 0, - }, tab: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -421,6 +414,7 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[1], + userSelect: "none", }, tabHandle: { flexDirection: "row", @@ -428,6 +422,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], flex: 1, minWidth: 0, + userSelect: "none", }, tabIcon: { flexShrink: 0, @@ -444,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, + userSelect: "none", }, tabLabelSkeleton: { width: 96, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 0982cdf0e..68ab81911 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -344,6 +344,10 @@ function WorkspaceScreenContent({ const isWorkspaceHeaderLoading = workspaceHeader === null; 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 desktopFileExplorerOpen = usePanelStore( (state) => state.desktop.fileExplorerOpen @@ -1025,6 +1029,20 @@ function WorkspaceScreenContent({ } }, [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( async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => { const { tabsToClose, title, logLabel } = input; @@ -1389,6 +1407,20 @@ function WorkspaceScreenContent({ > Copy workspace path + {currentBranchName ? ( + + } + onSelect={handleCopyBranchName} + > + Copy branch name + + ) : null} diff --git a/packages/app/src/styles/markdown-styles.test.ts b/packages/app/src/styles/markdown-styles.test.ts new file mode 100644 index 000000000..3c7021ae2 --- /dev/null +++ b/packages/app/src/styles/markdown-styles.test.ts @@ -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", + }); + }); +}); diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index d3e1ac3d5..6fc8c2e8a 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -18,10 +18,16 @@ export function createMarkdownStyles(theme: Theme) { color: theme.colors.foreground, fontSize: theme.fontSize.base, lineHeight: 22, + flexShrink: 1, + minWidth: 0, + width: "100%" as const, }, text: { color: theme.colors.foreground, + flexShrink: 1, + minWidth: 0, + overflowWrap: "anywhere" as const, }, paragraph: { @@ -31,6 +37,9 @@ export function createMarkdownStyles(theme: Theme) { flexDirection: "row" as const, alignItems: "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: { - color: theme.colors.primary, - textDecorationLine: "underline" as const, + color: theme.colors.accentBright, + textDecorationLine: "none" as const, + flexShrink: 1, + minWidth: 0, + overflowWrap: "anywhere" as const, }, blocklink: { - color: theme.colors.primary, - textDecorationLine: "underline" as const, + color: theme.colors.accentBright, + 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, paddingHorizontal: theme.spacing[1], paddingVertical: 2, - borderRadius: theme.borderRadius.sm, + borderRadius: theme.borderRadius.md, + borderWidth: 0, fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, }, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index f5080f351..1d58dbf3f 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -123,6 +123,7 @@ const lightSemanticColors = { // Brand accent: "#20744A", + accentBright: "#239956", accentForeground: "#ffffff", // Semantic @@ -192,6 +193,7 @@ const darkSemanticColors = { // Brand accent: "#20744A", + accentBright: "#7ccba0", accentForeground: "#ffffff", // Semantic diff --git a/packages/app/src/utils/tauri-daemon-transport.test.ts b/packages/app/src/utils/tauri-daemon-transport.test.ts new file mode 100644 index 000000000..a9d5e30f7 --- /dev/null +++ b/packages/app/src/utils/tauri-daemon-transport.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/utils/tauri-daemon-transport.ts b/packages/app/src/utils/tauri-daemon-transport.ts index c23a07cbe..bcf2ae62d 100644 --- a/packages/app/src/utils/tauri-daemon-transport.ts +++ b/packages/app/src/utils/tauri-daemon-transport.ts @@ -229,14 +229,10 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | return { ...transport, close: (code?: number, reason?: string) => { + wsListenerCleanup = null; transport.close(code, reason); disposed = true; - try { - wsListenerCleanup?.(); - } catch { - // no-op - } - wsListenerCleanup = null; + ws = null; }, }; }; diff --git a/packages/app/src/utils/tauri.ts b/packages/app/src/utils/tauri.ts index a62221e98..438541ad1 100644 --- a/packages/app/src/utils/tauri.ts +++ b/packages/app/src/utils/tauri.ts @@ -7,8 +7,22 @@ export interface TauriDialogAskOptions { kind?: "info" | "warning" | "error"; } +export interface TauriDialogOpenOptions { + title?: string; + defaultPath?: string; + directory?: boolean; + multiple?: boolean; + filters?: Array<{ + name: string; + extensions: string[]; + }>; +} + export interface TauriDialogApi { ask?: (message: string, options?: TauriDialogAskOptions) => Promise; + open?: ( + options?: TauriDialogOpenOptions + ) => Promise; } export interface TauriCoreApi {