ai on mobile

This commit is contained in:
Michael Freno
2025-12-28 14:40:38 -05:00
parent 5552bed2c0
commit ced9166b54
2 changed files with 80 additions and 27 deletions

View File

@@ -270,7 +270,10 @@ const KEYBOARD_SHORTCUTS: ShortcutCategory[] = [
{ keys: "→", keysAlt: "Right", description: "Accept word" },
{ keys: "⌥ Tab", keysAlt: "Alt Tab", description: "Accept line" },
{ keys: "⇧ Tab", keysAlt: "Shift Tab", description: "Accept full" },
{ keys: "ESC", keysAlt: "ESC", description: "Cancel suggestion" }
{ keys: "ESC", keysAlt: "ESC", description: "Cancel suggestion" },
{ keys: "Swipe →", keysAlt: "Swipe →", description: "Accept full (mobile fullscreen)" }
]
}
]
}
];
@@ -356,6 +359,7 @@ const IframeEmbed = Node.create<IframeOptions>({
}
});
const CONTEXT_SIZE = 512; // Characters before/after cursor for context for llm infill
const SWIPE_THRESHOLD = 100; // Swipe distance threshold in pixels (matches app.tsx)
// Custom Reference mark extension
import { Extension } from "@tiptap/core";
@@ -429,6 +433,7 @@ const SuggestionDecoration = Extension.create({
// Custom Reference mark extension
import { Mark, mergeAttributes } from "@tiptap/core";
import { Spinner } from "../Spinner";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -739,6 +744,10 @@ export default function TextEditor(props: TextEditorProps) {
const [infillEnabled, setInfillEnabled] = createSignal(true); // Toggle for auto-suggestions
let infillDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Touch gesture state for mobile AI suggestion acceptance
let touchStartX = 0;
let touchStartY = 0;
// Force reactive updates for button states
const [editorState, setEditorState] = createSignal(0);
@@ -777,7 +786,7 @@ export default function TextEditor(props: TextEditorProps) {
return `${baseClasses} ${activeClass} ${hoverClass}`.trim();
};
// Fetch infill config on mount (admin-only, desktop-only)
// Fetch infill config on mount (admin-only)
createEffect(async () => {
try {
const config = await api.infill.getConfig.query();
@@ -1441,6 +1450,51 @@ export default function TextEditor(props: TextEditorProps) {
return false;
},
handleDOMEvents: {
touchstart: (view, event) => {
// Only handle touch events on mobile in fullscreen with active suggestion
if (
!hasSuggestion() ||
!isFullscreen() ||
typeof window === "undefined" ||
window.innerWidth >= 768
) {
return false;
}
touchStartX = event.touches[0].clientX;
touchStartY = event.touches[0].clientY;
return false;
},
touchend: (view, event) => {
// Only handle touch events on mobile in fullscreen with active suggestion
if (
!hasSuggestion() ||
!isFullscreen() ||
typeof window === "undefined" ||
window.innerWidth >= 768
) {
return false;
}
const touchEndX = event.changedTouches[0].clientX;
const touchEndY = event.changedTouches[0].clientY;
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
// Check if horizontal swipe is dominant
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// Swipe right - accept full suggestion
if (deltaX > SWIPE_THRESHOLD) {
event.preventDefault();
acceptFull();
return true;
}
}
return false;
}
},
handleClickOn(view, pos, node, nodePos, event) {
const target = event.target as HTMLElement;
@@ -1489,8 +1543,15 @@ export default function TextEditor(props: TextEditorProps) {
}, 2000);
}
// Debounced infill trigger (250ms) - only if enabled
// Debounced infill trigger (250ms) - only if enabled and (desktop OR fullscreen mode)
if (infillConfig() && !isInitialLoad && infillEnabled()) {
const isMobileNotFullscreen =
typeof window !== "undefined" &&
window.innerWidth < 768 &&
!isFullscreen();
// Skip auto-infill on mobile when not in fullscreen
if (!isMobileNotFullscreen) {
if (infillDebounceTimer) {
clearTimeout(infillDebounceTimer);
}
@@ -1498,6 +1559,7 @@ export default function TextEditor(props: TextEditorProps) {
requestInfill();
}, 250);
}
}
});
},
onSelectionUpdate: ({ editor }) => {
@@ -3767,7 +3829,7 @@ export default function TextEditor(props: TextEditorProps) {
⌨ Help
</button>
{/* AI Autocomplete Toggle - Desktop only, shown when config available */}
{/* AI Autocomplete Toggle - shown when config available and (desktop OR fullscreen mode) */}
<Show when={infillConfig()}>
<button
type="button"
@@ -3782,10 +3844,13 @@ export default function TextEditor(props: TextEditorProps) {
infillEnabled()
? "bg-blue text-base"
: "bg-surface1 text-subtext0"
} hidden touch-manipulation rounded px-2 py-1 text-xs font-semibold transition-colors select-none md:block`}
} touch-manipulation rounded px-2 py-1 text-xs font-semibold transition-colors select-none`}
title={
infillEnabled()
? "AI Autocomplete: ON (Ctrl/Cmd+Space to trigger manually)"
? typeof window !== "undefined" &&
window.innerWidth < 768
? "AI Autocomplete: ON (swipe right to accept full)"
: "AI Autocomplete: ON (Ctrl/Cmd+Space to trigger manually)"
: "AI Autocomplete: OFF (Click to enable)"
}
>
@@ -4067,6 +4132,9 @@ export default function TextEditor(props: TextEditorProps) {
{/* Infill Loading Indicator */}
<Show when={isInfillLoading()}>
<div class="bg-surface0 border-surface2 text-subtext0 fixed right-4 bottom-4 z-50 animate-pulse rounded border px-3 py-2 text-xs shadow-lg">
<span>
<Spinner />
</span>
AI thinking...
</div>
</Show>

View File

@@ -1,14 +1,6 @@
import { publicProcedure, createTRPCRouter } from "~/server/api/utils";
import { env } from "~/env/server";
// Helper to detect mobile devices from User-Agent
const isMobileDevice = (userAgent: string | undefined): boolean => {
if (!userAgent) return false;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
userAgent
);
};
export const infillRouter = createTRPCRouter({
getConfig: publicProcedure.query(({ ctx }) => {
// Only admins get the config
@@ -16,15 +8,8 @@ export const infillRouter = createTRPCRouter({
return { endpoint: null, token: null };
}
// Get User-Agent from request headers
const userAgent = ctx.event.nativeEvent.node.req.headers["user-agent"];
// Block mobile devices - infill is desktop only
if (isMobileDevice(userAgent)) {
return { endpoint: null, token: null };
}
// Return endpoint and token (or null if not configured)
// Now supports both desktop and mobile (fullscreen mode)
return {
endpoint: env.VITE_INFILL_ENDPOINT || null,
token: env.INFILL_BEARER_TOKEN || null