diff --git a/apps/yaak-client/components/core/Button.tsx b/apps/yaak-client/components/core/Button.tsx index e4e20929..ca1a4db9 100644 --- a/apps/yaak-client/components/core/Button.tsx +++ b/apps/yaak-client/components/core/Button.tsx @@ -1,95 +1,24 @@ -import type { Color } from '@yaakapp-internal/plugins'; -import classNames from 'classnames'; -import type { HTMLAttributes, ReactNode } from 'react'; +import { + Button as BaseButton, + type ButtonProps as BaseButtonProps, +} from '@yaakapp-internal/ui'; import { forwardRef, useImperativeHandle, useRef } from 'react'; import type { HotkeyAction } from '../../hooks/useHotKey'; import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey'; -import { Icon, LoadingIcon } from '@yaakapp-internal/ui'; -export type ButtonProps = Omit, 'color' | 'onChange'> & { - innerClassName?: string; - color?: Color | 'custom' | 'default'; - variant?: 'border' | 'solid'; - isLoading?: boolean; - size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto'; - justify?: 'start' | 'center'; - type?: 'button' | 'submit'; - forDropdown?: boolean; - disabled?: boolean; - title?: string; - leftSlot?: ReactNode; - rightSlot?: ReactNode; +export type ButtonProps = BaseButtonProps & { hotkeyAction?: HotkeyAction; hotkeyLabelOnly?: boolean; hotkeyPriority?: number; }; export const Button = forwardRef(function Button( - { - isLoading, - className, - innerClassName, - children, - forDropdown, - color = 'default', - type = 'button', - justify = 'center', - size = 'md', - variant = 'solid', - leftSlot, - rightSlot, - disabled, - hotkeyAction, - hotkeyPriority, - hotkeyLabelOnly, - title, - onClick, - ...props - }: ButtonProps, + { hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: ButtonProps, ref, ) { const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join(''); const fullTitle = hotkeyTrigger ? `${title ?? ''} ${hotkeyTrigger}`.trim() : title; - if (isLoading) { - disabled = true; - } - - const classes = classNames( - className, - 'x-theme-button', - `x-theme-button--${variant}`, - `x-theme-button--${variant}--${color}`, - 'border', // They all have borders to ensure the same width - 'max-w-full min-w-0', // Help with truncation - 'hocus:opacity-100', // Force opacity for certain hover effects - 'whitespace-nowrap outline-none', - 'flex-shrink-0 flex items-center', - 'outline-0', - disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto', - justify === 'start' && 'justify-start', - justify === 'center' && 'justify-center', - size === 'md' && 'h-md px-3 rounded-md', - size === 'sm' && 'h-sm px-2.5 rounded-md', - size === 'xs' && 'h-xs px-2 text-sm rounded-md', - size === '2xs' && 'h-2xs px-2 text-xs rounded', - - // Solids - variant === 'solid' && 'border-transparent', - variant === 'solid' && color === 'custom' && 'focus-visible:outline-2 outline-border-focus', - variant === 'solid' && - color !== 'custom' && - 'text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle', - variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface', - - // Borders - variant === 'border' && 'border', - variant === 'border' && - color !== 'custom' && - 'border-border-subtle text-text-subtle enabled:hocus:border-border ' + - 'enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler', - ); - const buttonRef = useRef(null); useImperativeHandle( ref, @@ -104,43 +33,5 @@ export const Button = forwardRef(function Button { priority: hotkeyPriority, enable: !hotkeyLabelOnly }, ); - return ( - - ); + return ; }); diff --git a/apps/yaak-client/lib/initGlobalListeners.tsx b/apps/yaak-client/lib/initGlobalListeners.tsx index 7968406b..39fd364f 100644 --- a/apps/yaak-client/lib/initGlobalListeners.tsx +++ b/apps/yaak-client/lib/initGlobalListeners.tsx @@ -36,6 +36,19 @@ export function initGlobalListeners() { showToast({ ...event.payload }); }); + // Show errors for any plugins that failed to load during startup + invokeCmd<[string, string][]>("cmd_plugin_init_errors").then((errors) => { + for (const [dir, err] of errors) { + const name = dir.split(/[/\\]/).pop() ?? dir; + showToast({ + id: `plugin-init-error-${name}`, + color: "danger", + timeout: null, + message: `Failed to load plugin "${name}": ${err}`, + }); + } + }); + listenToTauriEvent("settings", () => openSettings.mutate(null)); // Track active dynamic form dialogs so follow-up input updates can reach them diff --git a/apps/yaak-client/lib/tauri.ts b/apps/yaak-client/lib/tauri.ts index 95fa68cc..470ebc32 100644 --- a/apps/yaak-client/lib/tauri.ts +++ b/apps/yaak-client/lib/tauri.ts @@ -41,6 +41,7 @@ type TauriCmd = | 'cmd_new_child_window' | 'cmd_new_main_window' | 'cmd_plugin_info' + | 'cmd_plugin_init_errors' | 'cmd_reload_plugins' | 'cmd_render_template' | 'cmd_save_response' diff --git a/apps/yaak-proxy/ActionButton.tsx b/apps/yaak-proxy/ActionButton.tsx index 717fd7bc..6d345449 100644 --- a/apps/yaak-proxy/ActionButton.tsx +++ b/apps/yaak-proxy/ActionButton.tsx @@ -1,10 +1,10 @@ -import { Button, type ButtonProps } from "@yaakapp-internal/ui"; -import { useCallback, useState } from "react"; -import type { ActionInvocation } from "@yaakapp-internal/proxy-lib"; -import { useActionMetadata } from "./hooks"; -import { rpc } from "./rpc"; +import type { ActionInvocation } from '@yaakapp-internal/proxy-lib'; +import { Button, type ButtonProps } from '@yaakapp-internal/ui'; +import { useCallback } from 'react'; +import { useActionMetadata } from './hooks'; +import { useRpcMutation } from './rpc-hooks'; -type ActionButtonProps = Omit & { +type ActionButtonProps = Omit & { action: ActionInvocation; /** Override the label from metadata */ children?: React.ReactNode; @@ -12,20 +12,20 @@ type ActionButtonProps = Omit & { export function ActionButton({ action, children, ...props }: ActionButtonProps) { const meta = useActionMetadata(action); - const [busy, setBusy] = useState(false); + const { mutate, isPending } = useRpcMutation('execute_action'); - const onClick = useCallback(async () => { - setBusy(true); - try { - await rpc("execute_action", action); - } finally { - setBusy(false); - } - }, [action]); + const onClick = useCallback(() => { + mutate(action); + }, [action, mutate]); return ( - ); } diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index 109b0008..2fe9dbbb 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -1,46 +1,47 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { type } from "@tauri-apps/plugin-os"; -import { HeaderSize } from "@yaakapp-internal/ui"; -import { ActionButton } from "./ActionButton"; -import { Sidebar } from "./Sidebar"; -import classNames from "classnames"; -import { createStore, Provider, useAtomValue } from "jotai"; -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./main.css"; -import { initHotkeys } from "./hotkeys"; -import { listen, rpc } from "./rpc"; -import { applyChange, dataAtom, httpExchangesAtom, replaceAll } from "./store"; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { type } from '@tauri-apps/plugin-os'; +import { HeaderSize } from '@yaakapp-internal/ui'; +import classNames from 'classnames'; +import { createStore, Provider, useAtomValue } from 'jotai'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ActionButton } from './ActionButton'; +import { Sidebar } from './Sidebar'; +import './main.css'; +import { initHotkeys } from './hotkeys'; +import { listen, rpc } from './rpc'; +import { useRpcQueryWithEvent } from './rpc-hooks'; +import { applyChange, dataAtom, httpExchangesAtom, replaceAll } from './store'; const queryClient = new QueryClient(); const jotaiStore = createStore(); // Load initial models from the database -rpc("list_models", {}).then((res) => { - jotaiStore.set(dataAtom, (prev) => - replaceAll(prev, "http_exchange", res.httpExchanges), - ); +rpc('list_models', {}).then((res) => { + jotaiStore.set(dataAtom, (prev) => replaceAll(prev, 'http_exchange', res.httpExchanges)); }); // Register hotkeys from action metadata initHotkeys(); // Subscribe to model change events from the backend -listen("model_write", (payload) => { +listen('model_write', (payload) => { jotaiStore.set(dataAtom, (prev) => - applyChange(prev, "http_exchange", payload.model, payload.change), + applyChange(prev, 'http_exchange', payload.model, payload.change), ); }); function App() { const osType = type(); const exchanges = useAtomValue(httpExchangesAtom); + const { data: proxyState } = useRpcQueryWithEvent('get_proxy_state', {}, 'proxy_state_changed'); + const isRunning = proxyState?.state === 'running'; return (
+ + {isRunning ? 'Running on :9090' : 'Stopped'} +
@@ -91,7 +102,7 @@ function App() { {ex.method} {ex.url} - {ex.resStatus ?? "—"} + {ex.resStatus ?? '—'} ))} @@ -104,7 +115,7 @@ function App() { ); } -createRoot(document.getElementById("root") as HTMLElement).render( +createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/apps/yaak-proxy/rpc-hooks.ts b/apps/yaak-proxy/rpc-hooks.ts new file mode 100644 index 00000000..1b5ea180 --- /dev/null +++ b/apps/yaak-proxy/rpc-hooks.ts @@ -0,0 +1,78 @@ +import { + useQuery, + useQueryClient, + useMutation, + type UseQueryOptions, + type UseMutationOptions, +} from "@tanstack/react-query"; +import { useEffect } from "react"; +import type { RpcEventSchema, RpcSchema } from "@yaakapp-internal/proxy-lib"; +import { minPromiseMillis } from "@yaakapp-internal/ui"; +import { listen, rpc } from "./rpc"; + +type Req = RpcSchema[K][0]; +type Res = RpcSchema[K][1]; + +/** + * React Query wrapper for RPC commands. + * Automatically caches by [cmd, payload] and supports all useQuery options. + */ +export function useRpcQuery( + cmd: K, + payload: Req, + opts?: Omit>, "queryKey" | "queryFn">, +) { + return useQuery>({ + queryKey: [cmd, payload], + queryFn: () => rpc(cmd, payload), + ...opts, + }); +} + +/** + * React Query mutation wrapper for RPC commands. + */ +export function useRpcMutation( + cmd: K, + opts?: Omit, Error, Req>, "mutationFn">, +) { + return useMutation, Error, Req>({ + mutationFn: (payload) => minPromiseMillis(rpc(cmd, payload)), + ...opts, + }); +} + +/** + * Subscribe to an RPC event. Cleans up automatically on unmount. + */ +export function useRpcEvent( + event: K & string, + callback: (payload: RpcEventSchema[K]) => void, +) { + useEffect(() => { + return listen(event, callback); + }, [event, callback]); +} + +/** + * Combines useRpcQuery with an event listener that invalidates the query + * whenever the specified event fires, keeping data fresh automatically. + */ +export function useRpcQueryWithEvent< + K extends keyof RpcSchema, + E extends keyof RpcEventSchema & string, +>( + cmd: K, + payload: Req, + event: E, + opts?: Omit>, "queryKey" | "queryFn">, +) { + const queryClient = useQueryClient(); + const query = useRpcQuery(cmd, payload, opts); + + useRpcEvent(event, () => { + queryClient.invalidateQueries({ queryKey: [cmd, payload] }); + }); + + return query; +} diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index d40577fa..850717c3 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -5,6 +5,10 @@ export type ActionInvocation = { "scope": "global", action: GlobalAction, }; export type ActionMetadata = { label: string, defaultHotkey: string | null, }; +export type GetProxyStateRequest = Record; + +export type GetProxyStateResponse = { state: ProxyState, }; + export type GlobalAction = "proxy_start" | "proxy_stop"; export type ListActionsRequest = Record; @@ -15,6 +19,10 @@ export type ListModelsRequest = Record; export type ListModelsResponse = { httpExchanges: Array, }; -export type RpcEventSchema = { model_write: ModelPayload, }; +export type ProxyState = "running" | "stopped"; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], }; +export type ProxyStatePayload = { state: ProxyState, }; + +export type RpcEventSchema = { model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, }; + +export type RpcSchema = { execute_action: [ActionInvocation, boolean], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], }; diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index b36459dd..36ddaf25 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -33,6 +33,22 @@ impl ProxyCtx { } } +// -- Proxy state -- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum ProxyState { + Running, + Stopped, +} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ProxyStatePayload { + pub state: ProxyState, +} + // -- Request/response types -- #[derive(Deserialize, TS)] @@ -56,6 +72,16 @@ pub struct ListModelsResponse { pub http_exchanges: Vec, } +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct GetProxyStateRequest {} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct GetProxyStateResponse { + pub state: ProxyState, +} + // -- Handlers -- fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result { @@ -81,6 +107,9 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result { @@ -89,12 +118,28 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result Result { + let handle = ctx + .handle + .lock() + .map_err(|_| RpcError { message: "lock poisoned".into() })?; + let state = if handle.is_some() { + ProxyState::Running + } else { + ProxyState::Stopped + }; + Ok(GetProxyStateResponse { state }) +} + fn list_actions(_ctx: &ProxyCtx, _req: ListActionsRequest) -> Result { Ok(ListActionsResponse { actions: crate::actions::all_global_actions(), @@ -216,10 +261,12 @@ define_rpc! { ProxyCtx; commands { execute_action(ActionInvocation) -> bool, + get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse, list_actions(ListActionsRequest) -> ListActionsResponse, list_models(ListModelsRequest) -> ListModelsResponse, } events { model_write(ModelPayload), + proxy_state_changed(ProxyStatePayload), } } diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 8afc0d4c..225fba29 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -1450,6 +1450,13 @@ async fn cmd_reload_plugins( Ok(()) } +#[tauri::command] +async fn cmd_plugin_init_errors( + plugin_manager: State<'_, PluginManager>, +) -> YaakResult> { + Ok(plugin_manager.take_init_errors().await) +} + #[tauri::command] async fn cmd_plugin_info( id: &str, @@ -1728,6 +1735,7 @@ pub fn run() { cmd_new_child_window, cmd_new_main_window, cmd_plugin_info, + cmd_plugin_init_errors, cmd_reload_plugins, cmd_render_template, cmd_restart, diff --git a/crates/yaak-plugins/src/manager.rs b/crates/yaak-plugins/src/manager.rs index 4f45493e..83c5d5eb 100644 --- a/crates/yaak-plugins/src/manager.rs +++ b/crates/yaak-plugins/src/manager.rs @@ -50,6 +50,8 @@ pub struct PluginManager { vendored_plugin_dir: PathBuf, pub(crate) installed_plugin_dir: PathBuf, dev_mode: bool, + /// Errors from plugin initialization, retrievable once via `take_init_errors`. + init_errors: Arc>>, } /// Callback for plugin initialization events (e.g., toast notifications) @@ -93,6 +95,7 @@ impl PluginManager { vendored_plugin_dir, installed_plugin_dir, dev_mode, + init_errors: Default::default(), }; // Forward events to subscribers @@ -183,17 +186,21 @@ impl PluginManager { let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await; if !init_errors.is_empty() { - let joined = init_errors - .into_iter() - .map(|(dir, err)| format!("{dir}: {err}")) - .collect::>() - .join("; "); - return Err(PluginErr(format!("Failed to initialize plugin(s): {joined}"))); + for (dir, err) in &init_errors { + error!("Failed to initialize plugin {dir}: {err}"); + } + *plugin_manager.init_errors.lock().await = init_errors; } Ok(plugin_manager) } + /// Take and clear any plugin initialization errors. + /// Returns the errors only once — subsequent calls return an empty list. + pub async fn take_init_errors(&self) -> Vec<(String, String)> { + std::mem::take(&mut *self.init_errors.lock().await) + } + /// Get the vendored plugin directory path (resolves dev mode path if applicable) pub fn get_plugins_dir(&self) -> PathBuf { if self.dev_mode { diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 59682377..1cf28ce6 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -1,5 +1,9 @@ import type { Color } from "@yaakapp-internal/plugins"; +import classNames from "classnames"; import type { HTMLAttributes, ReactNode } from "react"; +import { forwardRef } from "react"; +import { Icon } from "./Icon"; +import { LoadingIcon } from "./LoadingIcon"; type ButtonVariant = "border" | "solid"; type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto"; @@ -16,41 +20,43 @@ export type ButtonProps = Omit< size?: ButtonSize; justify?: "start" | "center"; type?: "button" | "submit"; + forDropdown?: boolean; disabled?: boolean; title?: string; leftSlot?: ReactNode; rightSlot?: ReactNode; }; -function cx(...values: Array) { - return values.filter(Boolean).join(" "); -} - -export function Button({ - isLoading, - className, - innerClassName, - children, - color, - tone, - type = "button", - justify = "center", - size = "md", - variant = "solid", - leftSlot, - rightSlot, - disabled, - title, - onClick, - ...props -}: ButtonProps) { +export const Button = forwardRef(function Button( + { + isLoading, + className, + innerClassName, + children, + color, + tone, + forDropdown, + type = "button", + justify = "center", + size = "md", + variant = "solid", + leftSlot, + rightSlot, + disabled, + title, + onClick, + ...props + }: ButtonProps, + ref, +) { const resolvedColor = color ?? tone ?? "default"; const isDisabled = disabled || isLoading; return ( ); -} +}); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d089c9f4..c182686c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -16,3 +16,4 @@ export type { TreeHandle, TreeProps } from "./components/tree/Tree"; export type { TreeNode } from "./components/tree/common"; export type { TreeItemProps } from "./components/tree/TreeItem"; export { isSelectedFamily, selectedIdsFamily } from "./components/tree/atoms"; +export { minPromiseMillis } from "./lib/minPromiseMillis"; diff --git a/packages/ui/src/lib/minPromiseMillis.ts b/packages/ui/src/lib/minPromiseMillis.ts new file mode 100644 index 00000000..c3628810 --- /dev/null +++ b/packages/ui/src/lib/minPromiseMillis.ts @@ -0,0 +1,16 @@ +export async function minPromiseMillis(promise: Promise, millis = 300): Promise { + const start = Date.now(); + let result: T; + + try { + result = await promise; + } catch (e) { + const remaining = millis - (Date.now() - start); + if (remaining > 0) await new Promise((r) => setTimeout(r, remaining)); + throw e; + } + + const remaining = millis - (Date.now() - start); + if (remaining > 0) await new Promise((r) => setTimeout(r, remaining)); + return result; +}