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 {