ai on mobile
This commit is contained in:
@@ -270,7 +270,10 @@ const KEYBOARD_SHORTCUTS: ShortcutCategory[] = [
|
|||||||
{ keys: "→", keysAlt: "Right", description: "Accept word" },
|
{ keys: "→", keysAlt: "Right", description: "Accept word" },
|
||||||
{ keys: "⌥ Tab", keysAlt: "Alt Tab", description: "Accept line" },
|
{ keys: "⌥ Tab", keysAlt: "Alt Tab", description: "Accept line" },
|
||||||
{ keys: "⇧ Tab", keysAlt: "Shift Tab", description: "Accept full" },
|
{ 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 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
|
// Custom Reference mark extension
|
||||||
import { Extension } from "@tiptap/core";
|
import { Extension } from "@tiptap/core";
|
||||||
@@ -429,6 +433,7 @@ const SuggestionDecoration = Extension.create({
|
|||||||
|
|
||||||
// Custom Reference mark extension
|
// Custom Reference mark extension
|
||||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||||
|
import { Spinner } from "../Spinner";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -739,6 +744,10 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
const [infillEnabled, setInfillEnabled] = createSignal(true); // Toggle for auto-suggestions
|
const [infillEnabled, setInfillEnabled] = createSignal(true); // Toggle for auto-suggestions
|
||||||
let infillDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
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
|
// Force reactive updates for button states
|
||||||
const [editorState, setEditorState] = createSignal(0);
|
const [editorState, setEditorState] = createSignal(0);
|
||||||
|
|
||||||
@@ -777,7 +786,7 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
return `${baseClasses} ${activeClass} ${hoverClass}`.trim();
|
return `${baseClasses} ${activeClass} ${hoverClass}`.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch infill config on mount (admin-only, desktop-only)
|
// Fetch infill config on mount (admin-only)
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
try {
|
try {
|
||||||
const config = await api.infill.getConfig.query();
|
const config = await api.infill.getConfig.query();
|
||||||
@@ -1441,6 +1450,51 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
|
|
||||||
return false;
|
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) {
|
handleClickOn(view, pos, node, nodePos, event) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
@@ -1489,8 +1543,15 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced infill trigger (250ms) - only if enabled
|
// Debounced infill trigger (250ms) - only if enabled and (desktop OR fullscreen mode)
|
||||||
if (infillConfig() && !isInitialLoad && infillEnabled()) {
|
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) {
|
if (infillDebounceTimer) {
|
||||||
clearTimeout(infillDebounceTimer);
|
clearTimeout(infillDebounceTimer);
|
||||||
}
|
}
|
||||||
@@ -1498,6 +1559,7 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
requestInfill();
|
requestInfill();
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSelectionUpdate: ({ editor }) => {
|
onSelectionUpdate: ({ editor }) => {
|
||||||
@@ -3767,7 +3829,7 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
⌨ Help
|
⌨ Help
|
||||||
</button>
|
</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()}>
|
<Show when={infillConfig()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3782,10 +3844,13 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
infillEnabled()
|
infillEnabled()
|
||||||
? "bg-blue text-base"
|
? "bg-blue text-base"
|
||||||
: "bg-surface1 text-subtext0"
|
: "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={
|
title={
|
||||||
infillEnabled()
|
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)"
|
: "AI Autocomplete: OFF (Click to enable)"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -4067,6 +4132,9 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
{/* Infill Loading Indicator */}
|
{/* Infill Loading Indicator */}
|
||||||
<Show when={isInfillLoading()}>
|
<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">
|
<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...
|
AI thinking...
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { publicProcedure, createTRPCRouter } from "~/server/api/utils";
|
import { publicProcedure, createTRPCRouter } from "~/server/api/utils";
|
||||||
import { env } from "~/env/server";
|
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({
|
export const infillRouter = createTRPCRouter({
|
||||||
getConfig: publicProcedure.query(({ ctx }) => {
|
getConfig: publicProcedure.query(({ ctx }) => {
|
||||||
// Only admins get the config
|
// Only admins get the config
|
||||||
@@ -16,15 +8,8 @@ export const infillRouter = createTRPCRouter({
|
|||||||
return { endpoint: null, token: null };
|
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)
|
// Return endpoint and token (or null if not configured)
|
||||||
|
// Now supports both desktop and mobile (fullscreen mode)
|
||||||
return {
|
return {
|
||||||
endpoint: env.VITE_INFILL_ENDPOINT || null,
|
endpoint: env.VITE_INFILL_ENDPOINT || null,
|
||||||
token: env.INFILL_BEARER_TOKEN || null
|
token: env.INFILL_BEARER_TOKEN || null
|
||||||
|
|||||||
Reference in New Issue
Block a user