feat: add permission onboarding page and startup routing for macOS

This commit is contained in:
CREDO23 2026-04-02 13:44:57 +02:00
parent ec2b7851b6
commit eaabad38fc
7 changed files with 228 additions and 7 deletions

View file

@ -0,0 +1,13 @@
import "./suggestion.css";
export const metadata = {
title: "SurfSense Suggestion",
};
export default function SuggestionLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="suggestion-body">{children}</div>;
}

View file

@ -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<string | null>(null);
const abortRef = useRef<AbortController | null>(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 (
<div className="suggestion-tooltip suggestion-error">
<span className="suggestion-error-text">{error}</span>
</div>
);
}
if (isLoading && !suggestion) {
return (
<div className="suggestion-tooltip">
<div className="suggestion-loading">
<span className="suggestion-dot" />
<span className="suggestion-dot" />
<span className="suggestion-dot" />
</div>
</div>
);
}
if (!suggestion) return null;
return (
<div className="suggestion-tooltip">
<p className="suggestion-text">{suggestion}</p>
<div className="suggestion-hint">
<span className="suggestion-key">Tab</span> accept
<span className="suggestion-separator">·</span>
<span className="suggestion-key">Esc</span> dismiss
</div>
</div>
);
}

View file

@ -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);
}
}