);
diff --git a/ui/web/src/components/ui/dialog.tsx b/ui/web/src/components/ui/dialog.tsx
index c666d4ef..6f373ad6 100644
--- a/ui/web/src/components/ui/dialog.tsx
+++ b/ui/web/src/components/ui/dialog.tsx
@@ -63,7 +63,9 @@ function DialogContent({
+
{toasts.slice(-3).map((t) => (
))}
diff --git a/ui/web/src/hooks/use-auto-scroll.ts b/ui/web/src/hooks/use-auto-scroll.ts
index ddd4d210..16040c27 100644
--- a/ui/web/src/hooks/use-auto-scroll.ts
+++ b/ui/web/src/hooks/use-auto-scroll.ts
@@ -20,25 +20,29 @@ export function useAutoScroll(
isNearBottom.current = scrollHeight - scrollTop - clientHeight < threshold;
}, [threshold]);
- const scrollToBottom = useCallback(() => {
+ const scrollToBottom = useCallback((instant = false) => {
const el = ref.current;
if (!el) return;
- el.scrollTop = el.scrollHeight;
+ if (instant) {
+ el.scrollTop = el.scrollHeight;
+ } else {
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+ }
}, []);
- // Auto-scroll when content changes (only if near bottom)
+ // Auto-scroll when content changes (only if near bottom) — smooth
useEffect(() => {
if (isNearBottom.current) {
- scrollToBottom();
+ scrollToBottom(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
- // Force scroll when trigger increments (e.g. user sends a message)
+ // Force scroll when trigger increments (e.g. user sends a message) — instant
useEffect(() => {
if (forceTrigger > 0) {
isNearBottom.current = true;
- scrollToBottom();
+ scrollToBottom(true);
}
}, [forceTrigger, scrollToBottom]);
diff --git a/ui/web/src/hooks/use-virtual-keyboard.ts b/ui/web/src/hooks/use-virtual-keyboard.ts
new file mode 100644
index 00000000..579f93c6
--- /dev/null
+++ b/ui/web/src/hooks/use-virtual-keyboard.ts
@@ -0,0 +1,28 @@
+import { useEffect } from "react";
+
+/**
+ * Tracks virtual keyboard height via VisualViewport API and exposes it
+ * as a CSS custom property `--keyboard-height` on the document root.
+ * Components can use `var(--keyboard-height, 0px)` to offset content.
+ *
+ * Call once near the app root (e.g. in the chat page).
+ */
+export function useVirtualKeyboard() {
+ useEffect(() => {
+ const vv = window.visualViewport;
+ if (!vv) return;
+
+ const update = () => {
+ const height = Math.max(0, window.innerHeight - vv.height);
+ document.documentElement.style.setProperty("--keyboard-height", `${height}px`);
+ };
+
+ vv.addEventListener("resize", update);
+ vv.addEventListener("scroll", update);
+ return () => {
+ vv.removeEventListener("resize", update);
+ vv.removeEventListener("scroll", update);
+ document.documentElement.style.removeProperty("--keyboard-height");
+ };
+ }, []);
+}
diff --git a/ui/web/src/index.css b/ui/web/src/index.css
index f3b9d750..788470a5 100644
--- a/ui/web/src/index.css
+++ b/ui/web/src/index.css
@@ -138,6 +138,38 @@
}
}
+/* ── Mobile UX utilities ── */
+
+/* Safe area padding for notched devices (requires viewport-fit=cover) */
+@utility safe-top { padding-top: env(safe-area-inset-top); }
+@utility safe-bottom { padding-bottom: env(safe-area-inset-bottom); }
+@utility safe-left { padding-left: env(safe-area-inset-left); }
+@utility safe-right { padding-right: env(safe-area-inset-right); }
+
+/* Allow word breaks anywhere for long URLs/text on narrow screens */
+@utility break-anywhere { overflow-wrap: anywhere; }
+
+/* Expand touch targets on coarse pointer (touch) devices for small icon buttons */
+@media (pointer: coarse) {
+ [data-slot="button"][data-size="icon"],
+ [data-slot="button"][data-size="icon-sm"],
+ [data-slot="button"][data-size="icon-xs"] {
+ position: relative;
+ }
+ [data-slot="button"][data-size="icon"]::after,
+ [data-slot="button"][data-size="icon-sm"]::after,
+ [data-slot="button"][data-size="icon-xs"]::after {
+ content: "";
+ position: absolute;
+ inset: -6px;
+ }
+}
+
+/* Landscape compact mode for phones */
+@media (orientation: landscape) and (max-height: 500px) {
+ .landscape-compact { padding-block: 0.25rem; }
+}
+
/* highlight.js: transparent bg, container handles it */
.hljs { background: transparent !important; }
diff --git a/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx b/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx
index 2c8a9477..0c09f3e9 100644
--- a/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx
+++ b/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx
@@ -203,7 +203,7 @@ function SortableProviderCard({ entry, index, toolName, enabledProviders, onUpda
{/* Row 2: provider + model selects */}
-
+