How to Implement a Custom Input Panel Synced with a Webview in Tauri
To sync a custom input panel with a Tauri webview, invoke Rust commands from your React frontend that use the eval method to execute JavaScript directly inside the target webview, enabling instantaneous state synchronization.
The lencx/ChatGPT desktop application demonstrates this pattern by splitting its interface into multiple webview panels—specifically the ask panel for input and the main panel for the chat interface. By leveraging Tauri’s command bridge and the eval API, the application keeps a native-feeling input component perfectly synchronized with the underlying web content without embedding the input inside the webview itself.
Architecture Overview
The synchronization relies on a four-layer architecture that separates the native input UI from the webview content while maintaining real-time communication:
- React UI Layer: Renders the textarea, manages local state, and debounces sync calls to avoid overwhelming the bridge.
- Tauri Command Bridge: Exposes Rust functions (
ask_sync,ask_send) that receive data from the frontend. - Webview Evaluator: Executes JavaScript inside the
mainwebview using Tauri’sevalmethod to update the webview’s internal state. - Global JavaScript Interface: A
ChatAskobject exposed onwindowinside the webview that receives commands from Rust.
This flow allows the native panel to handle OS-level focus, keyboard shortcuts, and input validation, while the webview manages the actual conversation state and API communication.
Creating the React Input Component
The custom input panel lives in src/view/Ask.tsx and uses React hooks to manage state and side effects. The component never directly manipulates the webview DOM; instead, it delegates all webview interactions to Tauri commands.
Managing State and Debounced Synchronization
User input is stored in React state and synchronized to the webview using a debounced effect to prevent excessive cross-boundary calls:
import { useState, useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import debounce from 'lodash/debounce';
export default function ChatInput() {
const [message, setMessage] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const syncMessage = debounce(async () => {
await invoke('ask_sync', { message: JSON.stringify(message) });
}, 300);
syncMessage();
return () => syncMessage.cancel();
}, [message]);
return (
<textarea
ref={inputRef}
value={message}
onChange={e => setMessage(e.target.value)}
className="w-full h-full resize-none"
autoFocus
/>
);
}
The invoke('ask_sync') call serializes the message and passes it to the Rust backend, where it is injected into the webview’s JavaScript context.
Handling Submission and Focus Restoration
When the user submits the message (via Cmd/Ctrl+Enter), the component triggers a send command and clears its state:
const handleSend = async () => {
if (!message) return;
await invoke('ask_send');
setMessage('');
inputRef.current?.focus();
};
The ask_send command handles the actual submission inside the webview and manages focus restoration back to the input panel after a short delay.
Bridging to the Webview with Rust Commands
In src-tauri/src/core/cmd.rs, two commands manage the communication between the native input and the webview content. Both commands access the main webview within the core window and evaluate JavaScript strings directly.
Implementing ask_sync
The ask_sync command takes the JSON-serialized message and calls ChatAsk.sync() inside the webview:
#[command]
pub fn ask_sync(app: AppHandle, message: String) {
app.get_window("core")
.unwrap()
.get_webview("main")
.unwrap()
.eval(&format!("ChatAsk.sync({})", message))
.unwrap();
}
This evaluates the JavaScript immediately, allowing the webview to update its preview or draft state without a full page reload.
Implementing ask_send with Focus Management
The ask_send command triggers the submission and returns focus to the input panel:
#[command]
pub fn ask_send(app: AppHandle) {
let win = app.get_window("core").unwrap();
win.get_webview("main")
.unwrap()
.eval(
r#"
ChatAsk.submit();
setTimeout(() => {
__TAURI__.webview.Webview.getByLabel('ask')?.setFocus();
}, 500);
"#,
)
.unwrap();
}
The setTimeout ensures the webview has processed the submission before returning focus to the ask panel webview.
Dispatching Views Based on Webview Labels
Tauri applications can host multiple webviews simultaneously. In src/App.tsx, the application determines which component to render based on the current webview’s label:
import { getCurrentWebview } from '@tauri-apps/api/webview';
import Titlebar from '~view/Titlebar';
import Ask from '~view/Ask';
import Settings from '~view/Settings';
const viewMap = {
titlebar: <Titlebar />,
ask: <Ask />,
settings: <Settings />,
};
export default function App() {
const webview = getCurrentWebview();
return viewMap[webview.label as keyof typeof viewMap] || null;
}
This design allows the Ask component to run in complete isolation as a separate webview (ask), while still controlling the main webview via the Rust command layer.
Handling Messages in the Target Webview
For the Rust commands to function, the main webview must expose a global ChatAsk object. This is typically defined in the bundled frontend entry point (e.g., src/main.tsx):
window.ChatAsk = {
sync(msg) {
console.log('Sync from native panel:', msg);
// Update conversation preview or draft storage
},
submit() {
console.log('Submit triggered from native panel');
// Execute actual API call or chat submission
},
};
The Rust side makes no assumptions about the internal implementation; it simply invokes these methods. This decoupling allows you to modify the webview’s internal logic without changing the native panel’s code.
Summary
- Use
invokefrom React to send state changes to Rust commands, debouncing frequent updates like keystrokes to optimize performance. - Leverage
evalin Rust to execute JavaScript inside a specific webview, targeting themainwebview to update its internal state via theChatAskinterface. - Manage multiple webviews by checking
getCurrentWebview().labelto render different components (titlebar, ask, settings) within the same application window. - Handle focus explicitly using
__TAURI__.webview.Webview.getByLabel().setFocus()to ensure smooth keyboard navigation between the native input and webview content. - Maintain separation of concerns by keeping the input UI in a dedicated webview while the content lives in another, bridged only through typed commands.
Frequently Asked Questions
How do I prevent performance issues when syncing every keystroke to the webview?
Use a debounce function (such as lodash/debounce) to limit the frequency of invoke calls. In src/view/Ask.tsx, the sync operation is wrapped in a 300ms debounce, ensuring the Rust bridge and eval calls only fire after the user pauses typing, preventing UI jank and excessive CPU usage.
Can I send data from the webview back to the custom input panel?
Yes. While the example shows native-to-webview communication, you can implement bidirectional sync by using Tauri’s emit and listen event system. The webview can call emit('update-input', data) and the React layer can subscribe with listen('update-input', handler) to update the textarea state from webview interactions.
Why use a separate webview for the input panel instead of embedding it in the main webview?
Separating the input into its own webview (labeled ask) provides native OS integration for focus management, keyboard shortcuts, and window controls that would be difficult to achieve inside a single webview. It also allows the input panel to persist and maintain state independently when the main webview navigates or reloads.
What happens if the ChatAsk object is not defined in the main webview?
The eval call will throw a JavaScript error (ReferenceError: ChatAsk is not defined) which propagates to the Rust unwrap() call, potentially panicking the application. Always ensure the global interface is initialized in the frontend before the native panel sends commands, or add error handling in Rust using if let or match blocks instead of unwrap().
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →