mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/azure-ocr
This commit is contained in:
commit
6038f6dfc0
84 changed files with 6041 additions and 1065 deletions
|
|
@ -19,6 +19,7 @@ import { OnboardingTour } from "@/components/onboarding-tour";
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useFolderSync } from "@/hooks/use-folder-sync";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
|
|
@ -139,6 +140,8 @@ export function DashboardClientLayout({
|
|||
refetchPreferences,
|
||||
]);
|
||||
|
||||
const electronAPI = useElectronAPI();
|
||||
|
||||
useEffect(() => {
|
||||
const activeSeacrhSpaceId =
|
||||
typeof search_space_id === "string"
|
||||
|
|
@ -148,7 +151,19 @@ export function DashboardClientLayout({
|
|||
: "";
|
||||
if (!activeSeacrhSpaceId) return;
|
||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||
}, [search_space_id, setActiveSearchSpaceIdState]);
|
||||
|
||||
// Sync to Electron store if stored value is null (first navigation)
|
||||
if (electronAPI?.setActiveSearchSpace) {
|
||||
electronAPI
|
||||
.getActiveSearchSpace?.()
|
||||
.then((stored) => {
|
||||
if (!stored) {
|
||||
electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [search_space_id, setActiveSearchSpaceIdState, electronAPI]);
|
||||
|
||||
// Determine if we should show loading
|
||||
const shouldShowLoading =
|
||||
|
|
|
|||
|
|
@ -1,30 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { BrainCog, Rocket, Zap } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
|
||||
export function DesktopContent() {
|
||||
const [isElectron, setIsElectron] = useState(false);
|
||||
const api = useElectronAPI();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||
|
||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) {
|
||||
if (!api) {
|
||||
setLoading(false);
|
||||
setShortcutsLoaded(true);
|
||||
return;
|
||||
}
|
||||
setIsElectron(true);
|
||||
|
||||
window.electronAPI.getAutocompleteEnabled().then((val) => {
|
||||
setEnabled(val);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
let mounted = true;
|
||||
|
||||
if (!isElectron) {
|
||||
Promise.all([
|
||||
api.getAutocompleteEnabled(),
|
||||
api.getShortcuts?.() ?? Promise.resolve(null),
|
||||
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
||||
searchSpacesApiService.getSearchSpaces(),
|
||||
])
|
||||
.then(([autoEnabled, config, spaceId, spaces]) => {
|
||||
if (!mounted) return;
|
||||
setEnabled(autoEnabled);
|
||||
if (config) setShortcuts(config);
|
||||
setActiveSpaceId(spaceId);
|
||||
if (spaces) setSearchSpaces(spaces);
|
||||
setLoading(false);
|
||||
setShortcutsLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setLoading(false);
|
||||
setShortcutsLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
if (!api) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -44,14 +85,120 @@ export function DesktopContent() {
|
|||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
setEnabled(checked);
|
||||
await window.electronAPI!.setAutocompleteEnabled(checked);
|
||||
await api.setAutocompleteEnabled(checked);
|
||||
};
|
||||
|
||||
const updateShortcut = (
|
||||
key: "generalAssist" | "quickAsk" | "autocomplete",
|
||||
accelerator: string
|
||||
) => {
|
||||
setShortcuts((prev) => {
|
||||
const updated = { ...prev, [key]: accelerator };
|
||||
api.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||
toast.error("Failed to update shortcut");
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
toast.success("Shortcut updated");
|
||||
};
|
||||
|
||||
const resetShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete") => {
|
||||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||
};
|
||||
|
||||
const handleSearchSpaceChange = (value: string) => {
|
||||
setActiveSpaceId(value);
|
||||
api.setActiveSearchSpace?.(value);
|
||||
toast.success("Default search space updated");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Default Search Space */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Autocomplete</CardTitle>
|
||||
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Choose which search space General Assist, Quick Assist, and Extreme Assist operate
|
||||
against.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
{searchSpaces.length > 0 ? (
|
||||
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a search space" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{searchSpaces.map((space) => (
|
||||
<SelectItem key={space.id} value={String(space.id)}>
|
||||
{space.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No search spaces found. Create one first.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Keyboard Shortcuts</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Customize the global keyboard shortcuts for desktop features.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
{shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.generalAssist}
|
||||
onChange={(accel) => updateShortcut("generalAssist", accel)}
|
||||
onReset={() => resetShortcut("generalAssist")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.generalAssist}
|
||||
label="General Assist"
|
||||
description="Launch SurfSense instantly from any application"
|
||||
icon={Rocket}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.quickAsk}
|
||||
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||
onReset={() => resetShortcut("quickAsk")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||
label="Quick Assist"
|
||||
description="Select text anywhere, then ask AI to explain, rewrite, or act on it"
|
||||
icon={Zap}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.autocomplete}
|
||||
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||
onReset={() => resetShortcut("autocomplete")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||
label="Extreme Assist"
|
||||
description="AI drafts text using your screen context and knowledge base"
|
||||
icon={BrainCog}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Click a shortcut and press a new key combination to change it.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Extreme Assist Toggle */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Extreme Assist</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Get inline writing suggestions powered by your knowledge base as you type in any app.
|
||||
</CardDescription>
|
||||
|
|
@ -60,7 +207,7 @@ export function DesktopContent() {
|
|||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="autocomplete-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Enable autocomplete
|
||||
Enable Extreme Assist
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show suggestions while typing in other applications.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
import { ensureTokensFromElectron, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
|
|
@ -17,15 +17,20 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
useGlobalLoadingEffect(isCheckingAuth);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
// Save current path and redirect to login
|
||||
redirectToLogin();
|
||||
return;
|
||||
async function checkAuth() {
|
||||
let token = getBearerToken();
|
||||
if (!token) {
|
||||
const synced = await ensureTokensFromElectron();
|
||||
if (synced) token = getBearerToken();
|
||||
}
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
|
||||
setIsCheckingAuth(false);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
|
||||
setIsCheckingAuth(false);
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Return null while loading - the global provider handles the loading UI
|
||||
|
|
|
|||
282
surfsense_web/app/desktop/login/page.tsx
Normal file
282
surfsense_web/app/desktop/login/page.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { BrainCog, Eye, EyeOff, Rocket, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { setBearerToken } from "@/lib/auth-utils";
|
||||
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
|
||||
|
||||
export default function DesktopLoginPage() {
|
||||
const router = useRouter();
|
||||
const api = useElectronAPI();
|
||||
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api?.getShortcuts) {
|
||||
setShortcutsLoaded(true);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.getShortcuts()
|
||||
.then((config) => {
|
||||
if (config) setShortcuts(config);
|
||||
setShortcutsLoaded(true);
|
||||
})
|
||||
.catch(() => setShortcutsLoaded(true));
|
||||
}, [api]);
|
||||
|
||||
const updateShortcut = useCallback(
|
||||
(key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => {
|
||||
setShortcuts((prev) => {
|
||||
const updated = { ...prev, [key]: accelerator };
|
||||
api?.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||
toast.error("Failed to update shortcut");
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
toast.success("Shortcut updated");
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
const resetShortcut = useCallback(
|
||||
(key: "generalAssist" | "quickAsk" | "autocomplete") => {
|
||||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||
},
|
||||
[updateShortcut]
|
||||
);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
||||
};
|
||||
|
||||
const autoSetSearchSpace = async () => {
|
||||
try {
|
||||
const stored = await api?.getActiveSearchSpace?.();
|
||||
if (stored) return;
|
||||
const spaces = await searchSpacesApiService.getSearchSpaces();
|
||||
if (spaces?.length) {
|
||||
await api?.setActiveSearchSpace?.(String(spaces[0].id));
|
||||
}
|
||||
} catch {
|
||||
// non-critical — dashboard-sync will catch it later
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoginError(null);
|
||||
|
||||
try {
|
||||
const data = await login({
|
||||
username: email,
|
||||
password,
|
||||
grant_type: "password",
|
||||
});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem("login_success_tracked", "true");
|
||||
}
|
||||
|
||||
setBearerToken(data.access_token);
|
||||
await autoSetSearchSpace();
|
||||
|
||||
setTimeout(() => {
|
||||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
}, 300);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setLoginError(err.message);
|
||||
} else {
|
||||
setLoginError("Login failed. Please check your credentials.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-svh items-center justify-center bg-background p-4 sm:p-6">
|
||||
{/* Subtle radial glow */}
|
||||
<div className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-1/2 left-1/2 size-[800px] -translate-x-1/2 rounded-full opacity-[0.03]"
|
||||
style={{
|
||||
background: "radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full max-w-md flex-col overflow-hidden rounded-xl border bg-card shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-center px-6 pt-6 pb-2 text-center">
|
||||
<Image
|
||||
src="/icon-128.svg"
|
||||
className="select-none dark:invert size-12 rounded-lg mb-3"
|
||||
alt="SurfSense"
|
||||
width={48}
|
||||
height={48}
|
||||
priority
|
||||
/>
|
||||
<h1 className="text-lg font-semibold tracking-tight">Welcome to SurfSense Desktop</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Configure shortcuts, then sign in to get started.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* ---- Shortcuts ---- */}
|
||||
{shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Keyboard Shortcuts
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.generalAssist}
|
||||
onChange={(accel) => updateShortcut("generalAssist", accel)}
|
||||
onReset={() => resetShortcut("generalAssist")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.generalAssist}
|
||||
label="General Assist"
|
||||
description="Launch SurfSense instantly from any application"
|
||||
icon={Rocket}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.quickAsk}
|
||||
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||
onReset={() => resetShortcut("quickAsk")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||
label="Quick Assist"
|
||||
description="Select text anywhere, then ask AI to explain, rewrite, or act on it"
|
||||
icon={Zap}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.autocomplete}
|
||||
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||
onReset={() => resetShortcut("autocomplete")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||
label="Extreme Assist"
|
||||
description="AI drafts text using your screen context and knowledge base"
|
||||
icon={BrainCog}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground text-center mt-1">
|
||||
Click a shortcut and press a new key combination to change it.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ---- Auth ---- */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Sign In
|
||||
</p>
|
||||
|
||||
{isGoogleAuth ? (
|
||||
<Button variant="outline" className="w-full gap-2 h-10" onClick={handleGoogleLogin}>
|
||||
<IconBrandGoogleFilled className="size-4" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleLocalLogin} className="flex flex-col gap-3">
|
||||
{loginError && (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="email" className="text-xs">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoggingIn}
|
||||
autoFocus
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-xs">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoggingIn}
|
||||
className="h-9 pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-muted-foreground hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoggingIn} className="h-9 mt-1">
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="text-primary-foreground" />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||
import { Logo } from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
||||
type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
|
||||
|
||||
|
|
@ -58,19 +59,18 @@ function StatusBadge({ status }: { status: PermissionStatus }) {
|
|||
|
||||
export default function DesktopPermissionsPage() {
|
||||
const router = useRouter();
|
||||
const api = useElectronAPI();
|
||||
const [permissions, setPermissions] = useState<PermissionsStatus | null>(null);
|
||||
const [isElectron, setIsElectron] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return;
|
||||
setIsElectron(true);
|
||||
if (!api) return;
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const isResolved = (s: string) => s === "authorized" || s === "restricted";
|
||||
|
||||
const poll = async () => {
|
||||
const status = await window.electronAPI!.getPermissionsStatus();
|
||||
const status = await api.getPermissionsStatus();
|
||||
setPermissions(status);
|
||||
|
||||
if (isResolved(status.accessibility) && isResolved(status.screenRecording)) {
|
||||
|
|
@ -83,9 +83,9 @@ export default function DesktopPermissionsPage() {
|
|||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [api]);
|
||||
|
||||
if (!isElectron) {
|
||||
if (!api) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">This page is only available in the desktop app.</p>
|
||||
|
|
@ -106,15 +106,15 @@ export default function DesktopPermissionsPage() {
|
|||
|
||||
const handleRequest = async (action: string) => {
|
||||
if (action === "requestScreenRecording") {
|
||||
await window.electronAPI!.requestScreenRecording();
|
||||
await api.requestScreenRecording();
|
||||
} else if (action === "requestAccessibility") {
|
||||
await window.electronAPI!.requestAccessibility();
|
||||
await api.requestAccessibility();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (allGranted) {
|
||||
window.electronAPI!.restartApp();
|
||||
api.restartApp();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -206,6 +206,7 @@ export default function DesktopPermissionsPage() {
|
|||
Grant permissions to continue
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { ensureTokensFromElectron, getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
type SSEEvent =
|
||||
| { type: "text-delta"; id: string; delta: string }
|
||||
|
|
@ -9,51 +10,108 @@ type SSEEvent =
|
|||
| { type: "text-end"; id: string }
|
||||
| { type: "start"; messageId: string }
|
||||
| { type: "finish" }
|
||||
| { type: "error"; errorText: string };
|
||||
| { type: "error"; errorText: string }
|
||||
| {
|
||||
type: "data-thinking-step";
|
||||
data: { id: string; title: string; status: string; items: string[] };
|
||||
}
|
||||
| {
|
||||
type: "data-suggestions";
|
||||
data: { options: string[] };
|
||||
};
|
||||
|
||||
function friendlyError(raw: string | number): string {
|
||||
interface AgentStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
type FriendlyError = { message: string; isSetup?: boolean };
|
||||
|
||||
function friendlyError(raw: string | number): FriendlyError {
|
||||
if (typeof raw === "number") {
|
||||
if (raw === 401) return "Please sign in to use suggestions.";
|
||||
if (raw === 403) return "You don\u2019t have permission for this.";
|
||||
if (raw === 404) return "Suggestion service not found. Is the backend running?";
|
||||
if (raw >= 500) return "Something went wrong on the server. Try again.";
|
||||
return "Something went wrong. Try again.";
|
||||
if (raw === 401) return { message: "Please sign in to use suggestions." };
|
||||
if (raw === 403) return { message: "You don\u2019t have permission for this." };
|
||||
if (raw === 404) return { message: "Suggestion service not found. Is the backend running?" };
|
||||
if (raw >= 500) return { message: "Something went wrong on the server. Try again." };
|
||||
return { message: "Something went wrong. Try again." };
|
||||
}
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower.includes("not authenticated") || lower.includes("unauthorized"))
|
||||
return "Please sign in to use suggestions.";
|
||||
return { message: "Please sign in to use suggestions." };
|
||||
if (lower.includes("no vision llm configured") || lower.includes("no llm configured"))
|
||||
return "No Vision LLM configured. Set one in search space settings.";
|
||||
return {
|
||||
message: "Configure a vision-capable model (e.g. GPT-4o, Gemini) to enable autocomplete.",
|
||||
isSetup: true,
|
||||
};
|
||||
if (lower.includes("does not support vision"))
|
||||
return "Selected model doesn\u2019t support vision. Set a vision-capable model in settings.";
|
||||
return {
|
||||
message: "The selected model doesn\u2019t support vision. Choose a vision-capable model.",
|
||||
isSetup: true,
|
||||
};
|
||||
if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused"))
|
||||
return "Can\u2019t reach the server. Check your connection.";
|
||||
return "Something went wrong. Try again.";
|
||||
return { message: "Can\u2019t reach the server. Check your connection." };
|
||||
return { message: "Something went wrong. Try again." };
|
||||
}
|
||||
|
||||
const AUTO_DISMISS_MS = 3000;
|
||||
|
||||
function StepIcon({ status }: { status: string }) {
|
||||
if (status === "complete") {
|
||||
return (
|
||||
<svg
|
||||
className="step-icon step-icon-done"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-label="Step complete"
|
||||
>
|
||||
<circle cx="8" cy="8" r="7" stroke="#4ade80" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M5 8.5l2 2 4-4.5"
|
||||
stroke="#4ade80"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return <span className="step-spinner" />;
|
||||
}
|
||||
|
||||
export default function SuggestionPage() {
|
||||
const [suggestion, setSuggestion] = useState("");
|
||||
const api = useElectronAPI();
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDesktop, setIsDesktop] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<FriendlyError | null>(null);
|
||||
const [steps, setSteps] = useState<AgentStep[]>([]);
|
||||
const [expandedOption, setExpandedOption] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.onAutocompleteContext) {
|
||||
setIsDesktop(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const isDesktop = !!api?.onAutocompleteContext;
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
if (!api?.onAutocompleteContext) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error || error.isSetup) return;
|
||||
const timer = setTimeout(() => {
|
||||
window.electronAPI?.dismissSuggestion?.();
|
||||
api?.dismissSuggestion?.();
|
||||
}, AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error]);
|
||||
}, [error, api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || error || options.length > 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
api?.dismissSuggestion?.();
|
||||
}, AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isLoading, error, options, api]);
|
||||
|
||||
const fetchSuggestion = useCallback(
|
||||
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
|
||||
|
|
@ -62,10 +120,16 @@ export default function SuggestionPage() {
|
|||
abortRef.current = controller;
|
||||
|
||||
setIsLoading(true);
|
||||
setSuggestion("");
|
||||
setOptions([]);
|
||||
setError(null);
|
||||
setSteps([]);
|
||||
setExpandedOption(null);
|
||||
|
||||
const token = getBearerToken();
|
||||
let token = getBearerToken();
|
||||
if (!token) {
|
||||
await ensureTokensFromElectron();
|
||||
token = getBearerToken();
|
||||
}
|
||||
if (!token) {
|
||||
setError(friendlyError("not authenticated"));
|
||||
setIsLoading(false);
|
||||
|
|
@ -123,10 +187,21 @@ export default function SuggestionPage() {
|
|||
|
||||
try {
|
||||
const parsed: SSEEvent = JSON.parse(data);
|
||||
if (parsed.type === "text-delta") {
|
||||
setSuggestion((prev) => prev + parsed.delta);
|
||||
if (parsed.type === "data-suggestions") {
|
||||
setOptions(parsed.data.options);
|
||||
} else if (parsed.type === "error") {
|
||||
setError(friendlyError(parsed.errorText));
|
||||
} else if (parsed.type === "data-thinking-step") {
|
||||
const { id, title, status, items } = parsed.data;
|
||||
setSteps((prev) => {
|
||||
const existing = prev.findIndex((s) => s.id === id);
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existing] = { id, title, status, items };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, { id, title, status, items }];
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
|
@ -143,9 +218,9 @@ export default function SuggestionPage() {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.onAutocompleteContext) return;
|
||||
if (!api?.onAutocompleteContext) return;
|
||||
|
||||
const cleanup = window.electronAPI.onAutocompleteContext((data) => {
|
||||
const cleanup = api.onAutocompleteContext((data) => {
|
||||
const searchSpaceId = data.searchSpaceId || "1";
|
||||
if (data.screenshot) {
|
||||
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
|
||||
|
|
@ -153,7 +228,7 @@ export default function SuggestionPage() {
|
|||
});
|
||||
|
||||
return cleanup;
|
||||
}, [fetchSuggestion]);
|
||||
}, [fetchSuggestion, api]);
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
|
|
@ -166,48 +241,140 @@ export default function SuggestionPage() {
|
|||
}
|
||||
|
||||
if (error) {
|
||||
if (error.isSetup) {
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-setup">
|
||||
<div className="setup-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="28" height="28" aria-hidden="true">
|
||||
<path
|
||||
d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="setup-content">
|
||||
<span className="setup-title">Vision Model Required</span>
|
||||
<span className="setup-message">{error.message}</span>
|
||||
<span className="setup-hint">Settings → Vision Models</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="setup-dismiss"
|
||||
onClick={() => api?.dismissSuggestion?.()}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-error">
|
||||
<span className="suggestion-error-text">{error}</span>
|
||||
<span className="suggestion-error-text">{error.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !suggestion) {
|
||||
const showLoading = isLoading && options.length === 0;
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<div className="suggestion-tooltip">
|
||||
<div className="suggestion-loading">
|
||||
<span className="suggestion-dot" />
|
||||
<span className="suggestion-dot" />
|
||||
<span className="suggestion-dot" />
|
||||
<div className="agent-activity">
|
||||
{steps.length === 0 && (
|
||||
<div className="activity-initial">
|
||||
<span className="step-spinner" />
|
||||
<span className="activity-label">Preparing…</span>
|
||||
</div>
|
||||
)}
|
||||
{steps.length > 0 && (
|
||||
<div className="activity-steps">
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="activity-step">
|
||||
<StepIcon status={step.status} />
|
||||
<span className="step-label">
|
||||
{step.title}
|
||||
{step.items.length > 0 && (
|
||||
<span className="step-detail"> · {step.items[0]}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAccept = () => {
|
||||
if (suggestion) {
|
||||
window.electronAPI?.acceptSuggestion?.(suggestion);
|
||||
}
|
||||
const handleSelect = (text: string) => {
|
||||
api?.acceptSuggestion?.(text);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
window.electronAPI?.dismissSuggestion?.();
|
||||
api?.dismissSuggestion?.();
|
||||
};
|
||||
|
||||
if (!suggestion) return null;
|
||||
const TRUNCATE_LENGTH = 120;
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-error">
|
||||
<span className="suggestion-error-text">No suggestions available.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suggestion-tooltip">
|
||||
<p className="suggestion-text">{suggestion}</p>
|
||||
<div className="suggestion-options">
|
||||
{options.map((option, index) => {
|
||||
const isExpanded = expandedOption === index;
|
||||
const needsTruncation = option.length > TRUNCATE_LENGTH;
|
||||
const displayText =
|
||||
needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="suggestion-option"
|
||||
onClick={() => handleSelect(option)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSelect(option);
|
||||
}}
|
||||
>
|
||||
<span className="option-number">{index + 1}</span>
|
||||
<span className="option-text">{displayText}</span>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
type="button"
|
||||
className="option-expand"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedOption(isExpanded ? null : index);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? "less" : "more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="suggestion-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="suggestion-btn suggestion-btn-accept"
|
||||
onClick={handleAccept}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="suggestion-btn suggestion-btn-dismiss"
|
||||
|
|
|
|||
|
|
@ -19,13 +19,21 @@ body:has(.suggestion-body) {
|
|||
}
|
||||
|
||||
.suggestion-tooltip {
|
||||
box-sizing: border-box;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 4px;
|
||||
max-width: 400px;
|
||||
/* MAX_HEIGHT in suggestion-window.ts is 400px. Subtract 8px for margin
|
||||
(4px * 2) so the tooltip + margin fits within the Electron window.
|
||||
box-sizing: border-box ensures padding + border are included. */
|
||||
max-height: 392px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
|
|
@ -35,6 +43,26 @@ body:has(.suggestion-body) {
|
|||
margin: 0 0 6px 0;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.suggestion-text::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.suggestion-text::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.suggestion-text::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.suggestion-text::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
|
|
@ -43,6 +71,7 @@ body:has(.suggestion-body) {
|
|||
gap: 4px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
padding-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestion-btn {
|
||||
|
|
@ -88,38 +117,234 @@ body:has(.suggestion-body) {
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.suggestion-loading {
|
||||
/* --- Setup prompt (vision model not configured) --- */
|
||||
|
||||
.suggestion-setup {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border-color: #3b2d6b;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.setup-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.setup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.setup-message {
|
||||
font-size: 11.5px;
|
||||
color: #a1a1aa;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
font-size: 10.5px;
|
||||
color: #7c6dac;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.setup-dismiss {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b6b7b;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.setup-dismiss:hover {
|
||||
color: #c4b5fd;
|
||||
background: rgba(124, 109, 172, 0.15);
|
||||
}
|
||||
|
||||
/* --- Agent activity indicator --- */
|
||||
|
||||
.agent-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
max-height: 340px;
|
||||
}
|
||||
|
||||
.agent-activity::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-initial {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.suggestion-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
.activity-label {
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.activity-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.activity-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
color: #d4d4d4;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.step-detail {
|
||||
color: #71717a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Spinner (in_progress) */
|
||||
.step-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid #3f3f46;
|
||||
border-top-color: #a78bfa;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
animation: suggestion-pulse 1.2s infinite ease-in-out;
|
||||
animation: step-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.suggestion-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
/* Checkmark icon (complete) */
|
||||
.step-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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.1);
|
||||
@keyframes step-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Suggestion option cards --- */
|
||||
|
||||
.suggestion-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.suggestion-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #333;
|
||||
background: #262626;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestion-option:hover {
|
||||
background: #2a2d3a;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.option-number {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #3f3f46;
|
||||
color: #d4d4d4;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.suggestion-option:hover .option-number {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
color: #d4d4d4;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.option-expand {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #71717a;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-family: inherit;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.option-expand:hover {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ZeroProvider } from "@/components/providers/ZeroProvider";
|
|||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||
import { PlatformProvider } from "@/contexts/platform-context";
|
||||
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -139,15 +140,17 @@ export default function RootLayout({
|
|||
disableTransitionOnChange
|
||||
defaultTheme="system"
|
||||
>
|
||||
<RootProvider>
|
||||
<ReactQueryClientProvider>
|
||||
<ZeroProvider>
|
||||
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||
</ZeroProvider>
|
||||
</ReactQueryClientProvider>
|
||||
<Toaster />
|
||||
<AnnouncementToastProvider />
|
||||
</RootProvider>
|
||||
<PlatformProvider>
|
||||
<RootProvider>
|
||||
<ReactQueryClientProvider>
|
||||
<ZeroProvider>
|
||||
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||
</ZeroProvider>
|
||||
</ReactQueryClientProvider>
|
||||
<Toaster />
|
||||
<AnnouncementToastProvider />
|
||||
</RootProvider>
|
||||
</PlatformProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</LocaleProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue