agentleFS
lukemurraynzlukemurraynz/hve-agent-skills/skills/tauri-desktop-ui-patterns/SKILL.md

tauri-desktop-ui-patterns

Skill2 starsChanged 3 months agoMITView on GitHub

Patterns for building desktop operator consoles in Tauri (React + TypeScript), focusing on real-time data binding, live list refresh, modal workflows, and keeping state synchronized with backend APIs. Covers schedule management, job triggers, run history, and recovery operations.

The file

We split this skill into the rules it gives an agent

12 rules. About 0 are common, 2 are distinctive, and 10 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 10

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Before generating production-shaped output with this skill, verify every identifier, command, version, status, and example against the …

    line 19
  2. Distinctive:

    Use this skill when building operator console panels for backend platforms in Tauri.

    line 26
  3. Very specific:

    Fix: Ensure sections exist in DOM before observer tries to observe them.

    line 353
  4. Very specific:

    Pattern: Keep API calls in a separate api.ts file.

    line 414
  5. Distinctive:

    Pattern: Keep types aligned with backend contracts.

    line 464
  6. Very specific:

    Use semantic HTML: <button>, <table>, <nav>, not <div> everywhere

    line 468
  7. Very specific:

    Add aria-label to icon buttons

    line 469
  8. Very specific:

    Add disabled attribute (not CSS-hidden) for non-interactive buttons

    line 470
  9. Very specific:

    Use <label> for form inputs with htmlFor binding

    line 471
  10. Very specific:

    Use tabindex=0 sparingly; semantic elements get keyboard focus automatically

    line 472
  11. Very specific:

    Memoize list item components: If each row is complex, use React.memo

    line 477
  12. Very specific:

    Pattern: Use waitFor for async updates, screen.getByText for stable selectors.

    line 499

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Tauri Desktop UI Patterns skill

Anti-Hallucination Rule

Before generating production-shaped output with this skill, verify every identifier, command, version, status, and example against the current authoritative source for the surfaces this skill covers: official product documentation, the CLI/SDK/reference surface, or the organisation's published standard. Anything that cannot be verified must be marked [VERIFY] and paired with the verification command or source. Do not produce final artifacts from memory when verification is blocked; emit a labelled skeleton with [VERIFY] markers and the verification commands instead.

Use this skill when building operator console panels for backend platforms in Tauri.

Overview

Tauri desktop apps that integrate with backend APIs need patterns for:

  • Real-time data display (runs, schedules, queue depth)
  • Live list refresh with polling or WebSocket
  • Modal forms for data entry (trigger job, register schedule)
  • Async operations with loading states
  • Error handling and toast notifications
  • Sidebar navigation between panels

This skill captures the patterns used in Execution Ledger's operator console.

Core patterns

1. Auto-polling list with refresh

Use when: Data changes externally and needs to stay current without user refresh.

// components/SchedulePanel.tsx
export function SchedulePanel({ session, busy, runAction }: Props) {
  const [schedules, setSchedules] = useState<Record<string, unknown>[]>([]);
  const [loadingSchedules, setLoadingSchedules] = useState(false);

  // Fetch function
  async function refreshSchedules() {
    setLoadingSchedules(true);
    try {
      const result = await listSchedules(session);
      setSchedules(result.schedules || []);
    } finally {
      setLoadingSchedules(false);
    }
  }

  // Auto-poll every 8 seconds if connected
  useEffect(() => {
    if (session.baseUrl && (session.bearerToken || session.gatewaySecret)) {
      refreshSchedules();  // Initial fetch
    }
  }, [session.baseUrl, session.bearerToken, session.gatewaySecret]);

  // Polling interval
  useEffect(() => {
    if (!session.baseUrl) return;
    const interval = setInterval(async () => {
      try { setSchedules(await listSchedules(session)); } catch { /* silent */ }
    }, 8000);
    return () => clearInterval(interval);
  }, [session.baseUrl, session.bearerToken, session.gatewaySecret]);

  return (
    <div>
      <button onClick={() => refreshSchedules()} disabled={loadingSchedules}>
        {loadingSchedules ? "Loading..." : "Refresh"}
      </button>
      {/* Render list */}
    </div>
  );
}

Tuning:

  • 8 seconds: Default for moderately volatile data (schedules, runs)
  • 3–5 seconds: For real-time operational dashboards
  • 30+ seconds: For stable reference data (templates, configuration)

Idempotency: Polling should be safe to call multiple times (no side effects). Keep fetch functions stateless.

Polling vs push: Polling is correct when the frontend talks to a REST API through Rust proxies. If the Rust side already subscribes to backend events, prefer forwarding lightweight events (listen) over poll ticks; events carry progress/notification-sized payloads, never bulk data (see Performance tips).

2. Modal form for data entry

Use when: Capturing structured input for a backend operation.

// Inside a panel component
const [isRegistering, setIsRegistering] = useState(false);
const [schedulerId, setSchedulerId] = useState("");
const [jobId, setJobId] = useState("");
const [cronExpression, setCronExpression] = useState("0 */4 * * *");

async function handleRegister() {
  if (!schedulerId.trim() || !jobId.trim()) {
    toast.addToast("Scheduler ID and job ID are required", "error");
    return;
  }
  await runAction("schedule-register", async () => {
    await registerSchedule(session, schedulerId, jobId, cronExpression);
    // Reset form and refresh list
    setSchedulerId("");
    setJobId("");
    setIsRegistering(false);
    await refreshSchedules();
  });
}

return (
  <>
    {!isRegistering ? (
      <button onClick={() => setIsRegistering(true)}>Register</button>
    ) : (
      <div>
        <input value={schedulerId} onChange={(e) => setSchedulerId(e.target.value)} />
        <input value={jobId} onChange={(e) => setJobId(e.target.value)} />
        <input value={cronExpression} onChange={(e) => setCronExpression(e.target.value)} />
        <button onClick={handleRegister}>Confirm</button>
        <button onClick={() => setIsRegistering(false)}>Cancel</button>
      </div>
    )}
  </>
);

Pattern: Toggle visibility state, keep form values in component state, use runAction to wrap async work with busy state + error toast.

3. Async action with global busy flag

Use when: Long-running operation should block other actions and show loading indicator.

// In top-level App component
const [busy, setBusy] = useState("");

async function runAction(id: string, fn: () => Promise<void>) {
  setBusy(id);
  try {
    await fn();
  } catch (e) {
    toast.addToast(e instanceof Error ? e.message : String(e), "error");
  } finally {
    setBusy("");
  }
}

// Pass down to child components
<SchedulePanel busy={busy} runAction={runAction} />

// Inside child, disable buttons while any action is running
<button disabled={busy !== ""} onClick={() => runAction("trigger", triggerJob)}>
  {busy === "trigger" ? "Triggering..." : "Trigger"}
</button>

Pattern: Single global busy state acts as a coarse-grained lock. Fine-grained options: use Record<string, boolean> if you need simultaneous independent operations. The bare wrapper above is the skeleton; the shipped form adds success/error toasts around the same body exactly as shown in pattern 6; implement it that way once, don't maintain two variants.

4. Table with toggle actions per row

Use when: List items have per-item actions (enable/disable, edit, delete).

<table>
  <thead><tr><th>ID</th><th>Status</th><th>Action</th></tr></thead>
  <tbody>
    {schedules.map((schedule, idx) => {
      const sId = String(schedule.scheduler_id || "");
      const enabled = schedule.enabled !== false;
      return (
        <tr key={sId || idx}>
          <td>{sId}</td>
          <td>{enabled ? "ENABLED" : "DISABLED"}</td>
          <td>
            <button
              disabled={busy !== ""}
              onClick={() => handleToggle(sId, enabled)}
            >
              {enabled ? "Disable" : "Enable"}
            </button>
          </td>
        </tr>
        );
      })}
      {!schedules.length && (
        <tr><td colSpan={3}>No schedules registered yet</td></tr>
      )}
    </tbody>
</table>

async function handleToggle(schedulerId: string, enabled: boolean) {
  await runAction(`schedule-toggle-${schedulerId}`, async () => {
    if (enabled) {
      await disableSchedule(session, schedulerId);
    } else {
      await enableSchedule(session, schedulerId);
    }
    await refreshSchedules();
  });
}

Pattern: Each row's data is derived from array item. Actions are closures capturing the row data. All buttons respect global busy state. After action completes, refresh the list.

5. Sidebar navigation with scroll-to behavior

Use when: App has multiple sections and switching between them should be smooth.

// Navigation items in sidebar
const navItems = [
  { id: "overview", label: "Dashboard", icon: Icons.dashboard },
  { id: "templates", label: "Job templates", icon: Icons.templates },
  { id: "trigger", label: "Manual trigger", icon: Icons.trigger },
  { id: "runs", label: "Runs", icon: Icons.runs },
  { id: "schedules", label: "Schedules", icon: Icons.trigger },  // <-- new
  { id: "actions", label: "Actions", icon: Icons.actions },
  { id: "recovery", label: "Recovery", icon: Icons.recovery },
];

// Main layout
<Sidebar activeSection={activeSection} scrollTo={scrollTo} navItems={navItems} />
<main>
  <Dashboard id="overview" />
  <JobTemplates id="templates" />
  <TriggerPanel id="trigger" />
  <RunTable id="runs" />
  <SchedulePanel id="schedules" />  {/* <-- new section */}
  <ActionPanel id="actions" />
  <RecoveryPanel id="recovery" />
</main>

// Scroll behavior
function scrollTo(id: string) {
  document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
}

// Track active section via IntersectionObserver
useEffect(() => {
  const sections = ["overview", "templates", "trigger", "runs", "schedules", "actions", "recovery"];
  const observer = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) { setActiveSection(entry.target.id); break; }
      }
    },
    { rootMargin: "-80px 0px -60% 0px" },
  );
  sections.forEach((id) => {
    const el = document.getElementById(id);
    if (el) observer.observe(el);
  });
  return () => observer.disconnect();
}, []);

Pattern: Each section gets a unique id attribute. IntersectionObserver tracks which section is currently visible. Sidebar nav items call scrollTo(id) which smoothly scrolls to that section.

6. Toast notifications for user feedback

Use when: Operations complete and you need to confirm success or report errors.

// Custom hook (from hooks/useToast.ts)
export function useToast() {
  const [toasts, setToasts] = useState<Toast[]>([]);
  
  function addToast(message: string, tone: "success" | "error" | "info") {
    const id = crypto.randomUUID();
    setToasts(prev => [...prev, { id, message, tone }]);
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== id));
    }, 5000);
  }
  
  return { toasts, addToast };
}

// Inside App
const toast = useToast();

async function runAction(id: string, fn: () => Promise<void>) {
  setBusy(id);
  try {
    await fn();
    toast.addToast("Success", "success");
  } catch (e) {
    toast.addToast(e instanceof Error ? e.message : String(e), "error");
  } finally {
    setBusy("");
  }
}

Pattern: Toast auto-dismisses after 5s. Success is implicit when no error is thrown. Errors are caught from the async function and displayed immediately.

Common pitfalls

"Polling causes flicker because we recreate the entire list on every refresh"

Fix: Compare old and new data before calling setState. Or use a map-based ID lookup to preserve identity.

// WRONG: Entire list re-renders even if data didn't change
async function refreshSchedules() {
  const result = await listSchedules(session);
  setSchedules(result.schedules || []);  // Always triggers re-render
}

// BETTER: Only set if data actually changed
async function refreshSchedules() {
  const result = await listSchedules(session);
  const newSchedules = result.schedules || [];
  setSchedules(prev => JSON.stringify(prev) === JSON.stringify(newSchedules) ? prev : newSchedules);
}

"Modal form persists old values when reopened"

Fix: Reset form state when toggling visibility.

// WRONG: Form values linger
function handleClose() {
  setIsOpen(false);
}

// CORRECT: Reset and close
function handleClose() {
  setSchedulerId("");
  setJobId("");
  setIsOpen(false);
}

"Navigation scroll doesn't work on first load"

Fix: Ensure sections exist in DOM before observer tries to observe them. Order useEffect so DOM is mounted first.

// WRONG: Observer runs before sections are rendered
useEffect(() => {
  setupObserver();
}, []);

return <div>{/* sections */}</div>;

// CORRECT: hooks live in the component body, never inside JSX
function Workspace() {
  useEffect(() => setupObserver(), []);
  return (
    <>
      <div id="overview">{/* content */}</div>
      <div id="templates">{/* content */}</div>
    </>
  );
}

// setupObserver must return its teardown so the observer disconnects on unmount
function setupObserver(): () => void {
  const observer = new IntersectionObserver(/* ... */);
  // ... observe sections ...
  return () => observer.disconnect();
}

"UI stutters after hours on Windows"

WebView2 can leak GDI objects on mouse-over/minimize cycles (Chromium-level regression, WebView2Feedback #5536), eventually hitting the 10,000-object per-process limit and causing system-wide cursor stutter. Watch GetGuiResources counts in long-running sessions; avoid toggling webview visibility as a workaround (it makes the leak worse). Report upstream; no host-side fix exists.

API integration patterns

Calling Tauri commands from React

// api.ts: Thin wrapper around Tauri invoke
export async function listSchedules(
  session: ApiSession,
  limit = 50,
): Promise<{ schedules: Record<string, unknown>[] }> {
  const tauriCore = await import("@tauri-apps/api/core");
  return tauriCore.invoke<{ schedules: Record<string, unknown>[] }>(
    "api_list_schedules",
    { session, limit },
  );
}

// Component: Call the wrapper, handle errors
async function handleLoadSchedules() {
  try {
    const result = await listSchedules(session);
    setSchedules(result.schedules);
  } catch (e) {
    toast.addToast(`Failed: ${e}`, "error");
  }
}

Pattern: Keep API calls in a separate api.ts file. Components call the wrappers, not Tauri directly.

Validating session connection

function ensureAuth(session: ApiSession) {
  if (!session.baseUrl.trim()) throw new Error("API base URL is required.");
  const hasBearer = session.bearerToken.trim().length > 0;
  const hasSecret = session.gatewaySecret.trim().length > 0;
  if (!hasBearer && !hasSecret) throw new Error("Set a bearer token or gateway secret.");
}

// Before any API call
async function triggerJobAction() {
  ensureAuth(session);
  const result = await triggerJob(session, jobId, payload);
  // ...
}

Pattern: Fail fast with clear error message if connection is incomplete.

Type safety

Define API response types

// types.ts
export interface JobRun {
  id: string;
  jobId: string;
  status: string;
  correlationId: string;
  createdAt?: string;
}

export interface ScenarioStep {
  id: string;
  connector: "platform_health" | "rest" | "queue" | "blob" | "azure_blob" | "sftp" | "erp";
  externalId?: string;
  data?: Record<string, unknown>;
}

// Component uses types
async function loadRun(runId: string) {
  const run: JobRun = await getRun(session, runId);
  setSelectedRun(run);
}

Pattern: Keep types aligned with backend contracts. Use unions for connector kinds and status enums.

Accessibility basics

  • Use semantic HTML: <button>, <table>, <nav>, not <div> everywhere
  • Add aria-label to icon buttons
  • Add disabled attribute (not CSS-hidden) for non-interactive buttons
  • Use <label> for form inputs with htmlFor binding
  • Use tabindex=0 sparingly; semantic elements get keyboard focus automatically

Performance tips

  • IPC payload discipline: treat invoke like an RPC control channel: small structured messages only. Large JSON lists and any binary (images, files) crossing the bridge serialize + copy + parse on the JS main thread: measured, base64+JS-decode blocks the main thread ~900ms per 2s at 30fps while a custom URI-scheme protocol blocks ~0ms (mechanicalrock.io 2026-06; benchmark harness iceinvein/tauri-ipc-bench). Keep heavy data in Rust and send summaries/IDs across the bridge. Serve bytes via a custom URI-scheme protocol (fetched as http://<scheme>.localhost/ on Windows) or stream chunks with ipc::Channel. Trap: returning Vec<u8>/ipc::Response from a command arrives in JS as a JSON number array, larger than base64 and parsed on the main thread; it is not an ArrayBuffer. Events are for lightweight progress ticks, not bulk transfer. When even a protocol doesn't fit, tauri-apps/tauri-invoke-http (official org repo) proxies invoke over a localhost server.
  • Memoize list item components: If each row is complex, use React.memo
  • Debounce search/filter input: 300ms default
  • Lazy-load icons: Inline SVG for <10 icons, loader for 50+
  • Pagination: Show top 50 items by default, load-more button for rest
  • Virtual scrolling: For 1000+ list items, use react-virtual or similar

Testing patterns

// Example: Testing SchedulePanel's refresh logic
import { render, screen, waitFor } from "@testing-library/react";

test("schedules refresh on connect", async () => {
  const session = { baseUrl: "http://test", bearerToken: "token", ... };
  render(<SchedulePanel session={session} busy="" runAction={async () => {}} />);
  
  await waitFor(() => {
    expect(screen.getByText("Active schedules")).toBeInTheDocument();
  });
});

Pattern: Use waitFor for async updates, screen.getByText for stable selectors.

Checklist for new panels

  • Auto-polling refreshes data (if external changes expected)
  • Modal form for data entry with input validation
  • List renders with fallback for empty state ("No items yet")
  • All buttons respect global busy state
  • After action, list refreshes automatically
  • Error messages are user-friendly (not stack traces); recoverable failures offer Retry and details affordances instead of dead-end toasts
  • Mutating or destructive actions require an explicit confirmation step that application code cannot bypass (approval gates must never be skippable)
  • Section ID added to nav items and scroll observer
  • Types are defined in types.ts
  • API calls are in api.ts, not inline
  • Toast notifications confirm success and report errors
  • Diagnostics affordance exists: one action exporting app version, OS/runtime versions, and recent log tail (no secrets); pattern proven at scale by GitHub Copilot app's /collect-debug-logs

Currency

  • Last reviewed: 2026-08-25 (verified against tauri v2.11.5 / @tauri-apps/api 2.11.1)
  • Pattern origin: internal Tauri operator-console project
  • Reference implementations mined: github.com/github/app (GA Tauri desktop app ; canvases, worktree session isolation, /collect-debug-logs diagnostics), iceinvein/tauri-ipc-bench (IPC transport benchmarks)

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.