diff --git a/surfsense_web/app/suggestion/layout.tsx b/surfsense_web/app/suggestion/layout.tsx
new file mode 100644
index 000000000..36b7e037b
--- /dev/null
+++ b/surfsense_web/app/suggestion/layout.tsx
@@ -0,0 +1,13 @@
+import "./suggestion.css";
+
+export const metadata = {
+ title: "SurfSense Suggestion",
+};
+
+export default function SuggestionLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return
{children}
;
+}
diff --git a/surfsense_web/app/suggestion/page.tsx b/surfsense_web/app/suggestion/page.tsx
new file mode 100644
index 000000000..14dfab3af
--- /dev/null
+++ b/surfsense_web/app/suggestion/page.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getBearerToken } from "@/lib/auth-utils";
+
+type SSEEvent =
+ | { type: "text-delta"; id: string; delta: string }
+ | { type: "text-start"; id: string }
+ | { type: "text-end"; id: string }
+ | { type: "start"; messageId: string }
+ | { type: "finish" }
+ | { type: "error"; errorText: string };
+
+export default function SuggestionPage() {
+ const [suggestion, setSuggestion] = useState("");
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const abortRef = useRef(null);
+
+ const fetchSuggestion = useCallback(
+ async (text: string, cursorPosition: number, searchSpaceId: string) => {
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ setIsLoading(true);
+ setSuggestion("");
+ setError(null);
+
+ const token = getBearerToken();
+ if (!token) {
+ setError("Not authenticated");
+ setIsLoading(false);
+ return;
+ }
+
+ const backendUrl =
+ process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
+
+ const params = new URLSearchParams({
+ text,
+ cursor_position: String(cursorPosition),
+ search_space_id: searchSpaceId,
+ });
+
+ try {
+ const response = await fetch(
+ `${backendUrl}/api/v1/autocomplete/stream?${params}`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ signal: controller.signal,
+ },
+ );
+
+ if (!response.ok) {
+ setError(`Error: ${response.status}`);
+ setIsLoading(false);
+ return;
+ }
+
+ if (!response.body) {
+ setError("No response body");
+ setIsLoading(false);
+ return;
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const events = buffer.split(/\r?\n\r?\n/);
+ buffer = events.pop() || "";
+
+ for (const event of events) {
+ const lines = event.split(/\r?\n/);
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ const data = line.slice(6).trim();
+ if (!data || data === "[DONE]") continue;
+
+ try {
+ const parsed: SSEEvent = JSON.parse(data);
+ if (parsed.type === "text-delta") {
+ setSuggestion((prev) => {
+ const updated = prev + parsed.delta;
+ window.electronAPI?.updateSuggestionText?.(updated);
+ return updated;
+ });
+ } else if (parsed.type === "error") {
+ setError(parsed.errorText);
+ }
+ } catch {
+ continue;
+ }
+ }
+ }
+ }
+ } catch (err) {
+ if (err instanceof DOMException && err.name === "AbortError") return;
+ setError("Failed to get suggestion");
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [],
+ );
+
+ useEffect(() => {
+ if (!window.electronAPI?.onAutocompleteContext) return;
+
+ const cleanup = window.electronAPI.onAutocompleteContext((data) => {
+ const searchSpaceId = data.searchSpaceId || "1";
+ fetchSuggestion(data.text, data.cursorPosition, searchSpaceId);
+ });
+
+ return cleanup;
+ }, [fetchSuggestion]);
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (isLoading && !suggestion) {
+ return (
+
+ );
+ }
+
+ if (!suggestion) return null;
+
+ return (
+
+
{suggestion}
+
+ Tab accept
+ ยท
+ Esc dismiss
+
+
+ );
+}
diff --git a/surfsense_web/app/suggestion/suggestion.css b/surfsense_web/app/suggestion/suggestion.css
new file mode 100644
index 000000000..e9471e7f8
--- /dev/null
+++ b/surfsense_web/app/suggestion/suggestion.css
@@ -0,0 +1,96 @@
+.suggestion-body {
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ overflow: hidden;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ user-select: none;
+ -webkit-app-region: no-drag;
+}
+
+.suggestion-tooltip {
+ background: rgba(30, 30, 30, 0.95);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ padding: 10px 14px;
+ margin: 4px;
+ max-width: 400px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
+ 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.suggestion-text {
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 13px;
+ line-height: 1.5;
+ margin: 0 0 8px 0;
+ word-wrap: break-word;
+ white-space: pre-wrap;
+}
+
+.suggestion-hint {
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 11px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.suggestion-key {
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 3px;
+ padding: 1px 5px;
+ font-size: 10px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.suggestion-separator {
+ margin: 0 2px;
+}
+
+.suggestion-error {
+ border-color: rgba(255, 80, 80, 0.3);
+}
+
+.suggestion-error-text {
+ color: rgba(255, 120, 120, 0.9);
+ font-size: 12px;
+}
+
+.suggestion-loading {
+ display: flex;
+ gap: 4px;
+ padding: 4px 0;
+}
+
+.suggestion-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.4);
+ animation: suggestion-pulse 1.2s infinite ease-in-out;
+}
+
+.suggestion-dot:nth-child(2) {
+ animation-delay: 0.15s;
+}
+
+.suggestion-dot:nth-child(3) {
+ animation-delay: 0.3s;
+}
+
+@keyframes suggestion-pulse {
+ 0%, 80%, 100% {
+ opacity: 0.3;
+ transform: scale(0.8);
+ }
+ 40% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts
index 9cf1aa596..a30358527 100644
--- a/surfsense_web/types/window.d.ts
+++ b/surfsense_web/types/window.d.ts
@@ -14,6 +14,12 @@ interface ElectronAPI {
setQuickAskMode: (mode: string) => Promise;
getQuickAskMode: () => Promise;
replaceText: (text: string) => Promise;
+ onAutocompleteContext: (callback: (data: { text: string; cursorPosition: number; searchSpaceId?: string }) => void) => () => void;
+ acceptSuggestion: (text: string) => Promise;
+ dismissSuggestion: () => Promise;
+ updateSuggestionText: (text: string) => Promise;
+ setAutocompleteEnabled: (enabled: boolean) => Promise;
+ getAutocompleteEnabled: () => Promise;
}
declare global {