Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ const { getDefaultConfig } = require("expo/metro-config");
const { withUniwindConfig } = require("uniwind/metro");
const extraThemes = require("./generated-uniwind-theme-names.json");

// Expo's native DOM export hashes asset filenames without rewriting split-chunk
// dependencies. A single DOM bundle also resolves locally when opened via file://.
process.env.EXPO_NO_BUNDLE_SPLITTING ??= "1";

/** @type {import("expo/metro-config").MetroConfig} */
const config = getDefaultConfig(__dirname);
const workspaceRoot = path.resolve(__dirname, "../..");
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"react-native-screens": "~4.26.0",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.15.4",
"react-native-web": "^0.21.2",
"react-native-webview": "^13.16.1",
"react-native-worklets": "0.10.1",
"shiki": "4.2.0",
Expand Down
100 changes: 100 additions & 0 deletions apps/mobile/src/features/threads/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { memo, useCallback, useState } from "react";
import { Modal, Pressable, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
import { CopyTextButton } from "../../components/CopyTextButton";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import MermaidDom from "./MermaidDom";

export const MermaidDiagram = memo(function MermaidDiagram({
source,
pending,
onRepair,
}: {
source: string;
pending: boolean;
onRepair?: ((prompt: string) => void) | undefined;
}) {
const { themeAppearance } = useAppearancePreferences();
const [expanded, setExpanded] = useState(false);
const [showSource, setShowSource] = useState(false);
const repair = useCallback(
async (prompt: string) => {
setExpanded(false);
onRepair?.(prompt);
},
[onRepair],
);
const content = (fullScreen: boolean) => (
<>
<View className="flex-row items-center gap-2 border-b border-border px-3 py-2">
<Text className="flex-1 text-xs text-foreground-muted">Mermaid</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={showSource ? "View diagram" : "View source"}
onPress={() => setShowSource(!showSource)}
className="min-h-11 justify-center px-2"
>
<Text className="text-xs">{showSource ? "Diagram" : "Source"}</Text>
</Pressable>
<CopyTextButton
text={source}
accessibilityLabel="Copy source"
buttonSize={44}
iconSize={16}
tintColor={themeAppearance === "dark" ? "#e4e4e7" : "#27272a"}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={fullScreen ? "Close diagram" : "Expand diagram"}
onPress={() => setExpanded(!fullScreen)}
className="min-h-11 justify-center px-2"
>
<Text className="text-xs">{fullScreen ? "Close" : "Expand"}</Text>
</Pressable>
</View>
{pending ? (
<View className="min-h-32 items-center justify-center p-6">
<Text className="text-xs text-foreground-muted">Waiting for diagram…</Text>
</View>
) : (
<MermaidDom
source={source}
theme={themeAppearance}
expanded={fullScreen}
showSource={showSource}
canRepair={Boolean(onRepair)}
onRepair={repair}
dom={{
useExpoDOMWebView: false,
scrollEnabled: false,
style: {
flex: fullScreen ? 1 : undefined,
height: fullScreen ? undefined : 320,
backgroundColor: "transparent",
},
}}
/>
)}
</>
);
return (
<View className="my-3 overflow-hidden rounded-xl border border-border bg-card">
{expanded ? (
<View className="h-96 items-center justify-center">
<Text className="text-xs text-foreground-muted">Diagram open</Text>
</View>
) : (
content(false)
)}
<Modal
visible={expanded}
animationType="slide"
presentationStyle="fullScreen"
onRequestClose={() => setExpanded(false)}
>
<SafeAreaView className="flex-1 bg-card">{expanded ? content(true) : null}</SafeAreaView>
</Modal>
</View>
);
});
208 changes: 208 additions & 0 deletions apps/mobile/src/features/threads/MermaidDom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"use dom";

import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { renderMermaid } from "@t3tools/client-runtime/mermaid-renderer";
import { mermaidRepairPrompt } from "@t3tools/client-runtime/mermaid";
import { mountDiagramViewport } from "@t3tools/client-runtime/mermaid-viewport";

const buttonStyle: CSSProperties = {
border: "1px solid #71717a",
borderRadius: 8,
padding: "10px 12px",
background: "transparent",
color: "inherit",
font: "inherit",
};

export default function MermaidDom({
source,
theme,
expanded,
showSource,
canRepair,
onRepair,
}: {
source: string;
theme: "light" | "dark";
expanded: boolean;
showSource: boolean;
canRepair: boolean;
onRepair: (prompt: string) => Promise<void>;
dom?: import("expo/dom").DOMProps;
}) {
const [result, setResult] = useState<{
source: string;
theme: string;
attempt: number;
svg?: string;
error?: string;
} | null>(null);
const [attempt, setAttempt] = useState(0);
const [scale, setScale] = useState(1);
const canvas = useRef<HTMLDivElement>(null);
const image = useRef<HTMLImageElement>(null);
const controls = useRef<ReturnType<typeof mountDiagramViewport> | null>(null);
const current =
result?.source === source && result.theme === theme && result.attempt === attempt
? result
: null;
const svg = current?.svg;
const imageSource = useMemo(
() => (svg ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` : undefined),
[svg],
);
const error = current?.error;
useEffect(() => {
let cancelled = false;
void renderMermaid(source, theme).then(
(svg) => {
if (!cancelled) setResult({ source, theme, attempt, svg });
},
(error: unknown) => {
if (!cancelled)
setResult({
source,
theme,
attempt,
error: error instanceof Error ? error.message : "Unable to render the diagram.",
});
},
);
return () => {
cancelled = true;
};
}, [source, theme, attempt]);
useEffect(() => {
if (!expanded || !svg || showSource || !canvas.current || !image.current) return;
const controller = mountDiagramViewport(canvas.current, image.current, svg, setScale);
controls.current = controller;
return () => {
controller.dispose();
controls.current = null;
};
}, [expanded, svg, showSource]);
const background = theme === "dark" ? "#18181b" : "#ffffff";
return (
<div
style={{
height: "100dvh",
width: "100%",
display: "flex",
flexDirection: "column",
background,
color: theme === "dark" ? "#e4e4e7" : "#27272a",
font: "14px system-ui",
overflow: "hidden",
}}
>
<style>
{
"html,body,#root{margin:0;height:100%;} *{box-sizing:border-box} button:focus-visible{outline:2px solid #6489c6}"
}
</style>
{expanded && svg && !showSource ? (
<div
style={{ display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center", padding: 8 }}
>
<button
style={buttonStyle}
aria-label="Zoom out"
onClick={() => controls.current?.zoom(1 / 1.2)}
>
-
</button>
<output aria-label="Zoom level" style={{ minWidth: 40, textAlign: "center" }}>
{Math.round(scale * 100)}%
</output>
<button
style={buttonStyle}
aria-label="Zoom in"
onClick={() => controls.current?.zoom(1.2)}
>
+
</button>
<button style={buttonStyle} onClick={() => controls.current?.fit()}>
Fit
</button>
<button style={buttonStyle} onClick={() => controls.current?.center()}>
Center
</button>
<button style={buttonStyle} onClick={() => controls.current?.actualSize()}>
100%
</button>
</div>
) : null}
{error || showSource ? (
<div style={{ padding: 16, overflow: "auto", flex: 1 }}>
{error ? (
<>
<p role="status">Couldn’t render this diagram</p>
<details>
<summary>Error details</summary>
<pre style={{ whiteSpace: "pre-wrap" }}>{error}</pre>
</details>
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button
style={buttonStyle}
onClick={() => {
setResult(null);
setAttempt((value) => value + 1);
}}
>
Retry
</button>
{canRepair ? (
<button
style={buttonStyle}
onClick={() => void onRepair(mermaidRepairPrompt(source, error))}
>
Ask agent to fix
</button>
) : null}
</div>
</>
) : null}
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
<code>{source}</code>
</pre>
</div>
) : svg ? (
<div
ref={canvas}
tabIndex={expanded ? 0 : undefined}
role="region"
aria-label="Diagram canvas"
style={{
position: "relative",
flex: 1,
minHeight: 0,
overflow: "hidden",
touchAction: expanded ? "none" : "pan-y",
cursor: expanded ? "grab" : "default",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: expanded ? 0 : 16,
}}
>
<img
ref={image}
src={imageSource}
alt="Mermaid diagram"
draggable={false}
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
</div>
) : (
<p role="status" style={{ padding: 24, textAlign: "center" }}>
Rendering diagram…
</p>
)}
{expanded && !showSource ? (
<p style={{ padding: "8px 16px", margin: 0, fontSize: 12, opacity: 0.7 }}>
Drag to pan · Pinch to zoom · F fit · C center · 0 actual size
</p>
) : null}
</div>
);
}
17 changes: 17 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
composerEditorRef.current?.blur();
}, []);

const handleRepairMermaid = useCallback(
(prompt: string) => {
const draft = draftMessageRef.current;
const nextDraft = draft.trim() ? `${draft}\n\n${prompt}` : prompt;
draftMessageRef.current = nextDraft;
props.onChangeDraftMessage(nextDraft);
requestAnimationFrame(() => {
composerEditorRef.current?.focus();
composerEditorRef.current?.setSelection({ start: nextDraft.length, end: nextDraft.length });
});
},
[props.onChangeDraftMessage],
);

const handleUseArtifactTemplate = useCallback(
(template: CodexArtifactTemplate) => {
const currentDraft = draftMessageRef.current;
Expand Down Expand Up @@ -880,6 +894,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onEndFollowEnabledChange={setEndFollowEnabled}
skills={selectedProviderSkills}
onUseArtifactTemplate={handleUseArtifactTemplate}
onRepairMermaid={
props.selectedThread.id.startsWith("t3sync-") ? undefined : handleRepairMermaid
}
loadEarlier={props.loadEarlier ?? null}
/>
</View>
Expand Down
Loading
Loading