mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 06:17:41 +00:00
feat(ui): mobile UX improvements for web dashboard (#141)
- Replace h-screen with h-dvh for correct mobile viewport height - Fix input font-size to 16px on mobile preventing iOS Safari auto-zoom - Add safe area padding for notched devices (Dynamic Island, home indicator) - Expand touch targets for icon buttons to ≥44px on touch devices - Make dialogs full-screen on mobile with slide-up animation - Add virtual keyboard detection for chat input positioning - Smooth auto-scroll for incoming messages, instant scroll on user send - Add overflow-x-auto to table pages for mobile horizontal scroll - Fix grid layouts to stack on mobile (grid-cols-1 sm:grid-cols-2) - Safe-area-aware toast positioning - Landscape compact mode for phone landscape orientation
This commit is contained in:
1 parent
ec11698fff
commit
e9e0a4e813
39 files changed
+139
-68
No files matched your search
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>GoClaw Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -44,7 +44,10 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputPro
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2 border-t bg-background p-4">
|
||||
<div
|
||||
className="flex items-end gap-2 border-t bg-background p-4 safe-bottom"
|
||||
style={{ paddingBottom: `calc(1rem + env(safe-area-inset-bottom) + var(--keyboard-height, 0px))` }}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
@@ -54,7 +57,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputPro
|
||||
placeholder={t("sendMessage")}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-base md:text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
{isRunning ? (
|
||||
<Button
|
||||
|
||||
@@ -17,7 +17,7 @@ export function AppLayout() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<div className="flex h-dvh overflow-hidden safe-top">
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
@@ -30,7 +30,7 @@ export function AppLayout() {
|
||||
{/* Slide-out sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 transition-transform duration-200 ease-in-out",
|
||||
"fixed inset-y-0 left-0 z-50 safe-top safe-bottom safe-left transition-transform duration-200 ease-in-out",
|
||||
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Topbar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b bg-background px-4">
|
||||
<header className="flex h-14 items-center justify-between border-b bg-background px-4 landscape-compact">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSidebarToggle}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function ProviderModelSelect({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={providerTip ?? t("providerTip")}>{providerLabel ?? t("provider")}</InfoLabel>
|
||||
{enabledProviders.length > 0 ? (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ROUTES } from "@/lib/constants";
|
||||
|
||||
function SetupLoader() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex h-dvh items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,9 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-6 duration-200 outline-none sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed z-50 grid w-full gap-4 border p-4 shadow-lg duration-200 outline-none",
|
||||
"sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:top-[50%] sm:left-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%] sm:max-w-lg sm:rounded-lg sm:p-6",
|
||||
"max-sm:inset-0 max-sm:overflow-y-auto max-sm:safe-bottom max-sm:data-[state=open]:slide-in-from-bottom-4 max-sm:data-[state=closed]:slide-out-to-bottom-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -37,7 +37,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function Toaster() {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col-reverse gap-2 pointer-events-none sm:bottom-6 sm:right-6">
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col-reverse gap-2 pointer-events-none sm:bottom-6 sm:right-6 safe-bottom safe-right">
|
||||
{toasts.slice(-3).map((t) => (
|
||||
<ToastItem key={t.id} toast={t} />
|
||||
))}
|
||||
|
||||
@@ -20,25 +20,29 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
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]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ function SortableProviderCard({ entry, index, toolName, enabledProviders, onUpda
|
||||
</div>
|
||||
|
||||
{/* Row 2: provider + model selects */}
|
||||
<div className="grid grid-cols-2 gap-2 px-3 py-1.5">
|
||||
<div className="grid grid-cols-1 gap-2 px-3 py-1.5 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.provider")}</Label>
|
||||
<Select value={entry.provider} onValueChange={handleProviderChange}>
|
||||
@@ -274,7 +274,7 @@ function SortableProviderCard({ entry, index, toolName, enabledProviders, onUpda
|
||||
|
||||
{/* Collapsible params */}
|
||||
{expanded && paramSchema.length > 0 && (
|
||||
<div className="border-t px-3 py-3 grid grid-cols-2 gap-3">
|
||||
<div className="border-t px-3 py-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{paramSchema.map((field) => (
|
||||
<ParamFieldControl
|
||||
key={field.key}
|
||||
|
||||
@@ -81,7 +81,7 @@ function InlineAddForm({ groupId, showGroupField, listContacts, onAdd }: InlineA
|
||||
<fieldset className="rounded-md border p-4 space-y-3">
|
||||
<legend className="px-1 text-sm font-medium">{t("detail.managers.addForm.title")}</legend>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.managers.addForm.hint")}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.groupId")}</Label>
|
||||
<Input
|
||||
@@ -324,8 +324,8 @@ export function ChannelManagersTab({
|
||||
) : groupManagers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">{t("detail.managers.noManagers")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border bg-background">
|
||||
<table className="w-full min-w-[500px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.managers.columns.userId")}</th>
|
||||
|
||||
@@ -144,8 +144,8 @@ export function ChannelsPage() {
|
||||
description={debouncedSearch ? t("noMatchDescription") : t("emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useChatSessions } from "./hooks/use-chat-sessions";
|
||||
import { useChatMessages } from "./hooks/use-chat-messages";
|
||||
import { useChatSend } from "./hooks/use-chat-send";
|
||||
import { isOwnSession, parseSessionKey } from "@/lib/session-key";
|
||||
import { useVirtualKeyboard } from "@/hooks/use-virtual-keyboard";
|
||||
|
||||
export function ChatPage() {
|
||||
const { t } = useTranslation("chat");
|
||||
@@ -129,6 +130,7 @@ export function ChatPage() {
|
||||
}, [abort, sessionKey]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
useVirtualKeyboard();
|
||||
const [chatSidebarOpen, setChatSidebarOpen] = useState(false);
|
||||
|
||||
const handleSessionSelectMobile = useCallback(
|
||||
@@ -187,7 +189,7 @@ export function ChatPage() {
|
||||
{/* Main chat area */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{isMobile && (
|
||||
<div className="flex items-center border-b px-3 py-2">
|
||||
<div className="flex items-center border-b px-3 py-2 landscape-compact">
|
||||
<button
|
||||
onClick={() => setChatSidebarOpen(true)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ChatThread({
|
||||
<div
|
||||
ref={ref}
|
||||
onScroll={onScroll}
|
||||
className="flex-1 overflow-y-auto px-4 py-4"
|
||||
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
|
||||
@@ -86,7 +86,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
|
||||
showVerify
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("agents.maxTokensTip")}>{t("agents.maxTokens")}</InfoLabel>
|
||||
<Input
|
||||
@@ -128,7 +128,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("agents.workspaceTip")}>{t("agents.workspace")}</InfoLabel>
|
||||
<Input
|
||||
@@ -153,7 +153,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
|
||||
open={openSubs.has("subagents")}
|
||||
onToggle={() => toggleSub("subagents")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label={t("agents.subagents.maxConcurrent")} tip={t("agents.subagents.maxConcurrentTip")} type="number" value={subagents.maxConcurrent} onChange={(v) => updateNested("subagents", { maxConcurrent: Number(v) })} placeholder="20" />
|
||||
<Field label={t("agents.subagents.maxSpawnDepth")} tip={t("agents.subagents.maxSpawnDepthTip")} type="number" value={subagents.maxSpawnDepth} onChange={(v) => updateNested("subagents", { maxSpawnDepth: Number(v) })} placeholder="1" />
|
||||
<Field label={t("agents.subagents.maxChildrenPerAgent")} tip={t("agents.subagents.maxChildrenPerAgentTip")} type="number" value={subagents.maxChildrenPerAgent} onChange={(v) => updateNested("subagents", { maxChildrenPerAgent: Number(v) })} placeholder="5" />
|
||||
@@ -185,7 +185,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
|
||||
modelPlaceholder="text-embedding-3-small"
|
||||
allowEmpty
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label={t("agents.memory.maxResults")} tip={t("agents.memory.maxResultsTip")} type="number" value={memory.max_results} onChange={(v) => updateNested("memory", { max_results: Number(v) })} placeholder="6" />
|
||||
<Field label={t("agents.memory.minScore")} tip={t("agents.memory.minScoreTip")} type="number" step="0.01" value={memory.min_score} onChange={(v) => updateNested("memory", { min_score: Number(v) })} placeholder="0.35" />
|
||||
</div>
|
||||
@@ -197,7 +197,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
|
||||
open={openSubs.has("compaction")}
|
||||
onToggle={() => toggleSub("compaction")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label={t("agents.compaction.reserveTokensFloor")} tip={t("agents.compaction.reserveTokensFloorTip")} type="number" value={compaction.reserveTokensFloor} onChange={(v) => updateNested("compaction", { reserveTokensFloor: Number(v) })} placeholder="20000" />
|
||||
<Field label={t("agents.compaction.maxHistoryShare")} tip={t("agents.compaction.maxHistoryShareTip")} type="number" step="0.05" value={compaction.maxHistoryShare} onChange={(v) => updateNested("compaction", { maxHistoryShare: Number(v) })} placeholder="0.75" />
|
||||
</div>
|
||||
@@ -209,7 +209,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
|
||||
open={openSubs.has("pruning")}
|
||||
onToggle={() => toggleSub("pruning")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("agents.pruning.mode")}</Label>
|
||||
<Select value={pruning.mode ?? "off"} onValueChange={(v) => updateNested("contextPruning", { mode: v })}>
|
||||
@@ -230,7 +230,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
|
||||
open={openSubs.has("sandbox")}
|
||||
onToggle={() => toggleSub("sandbox")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("agents.sandbox.mode")}</Label>
|
||||
<Select value={sandbox.mode ?? "off"} onValueChange={(v) => updateNested("sandbox", { mode: v })}>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function BindingsSection({ data, onSave, saving }: Props) {
|
||||
) : (
|
||||
draft.map((binding, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 rounded-md border p-3">
|
||||
<div className="grid flex-1 grid-cols-4 gap-3">
|
||||
<div className="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs">{t("bindings.agentId")}</Label>
|
||||
<Input
|
||||
|
||||
@@ -140,7 +140,7 @@ export function ChannelsSection({ data, onSave, saving }: Props) {
|
||||
)}
|
||||
|
||||
{/* Policies */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{chData.dm_policy !== undefined && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("channels.dmPolicy")}</Label>
|
||||
@@ -191,7 +191,7 @@ export function ChannelsSection({ data, onSave, saving }: Props) {
|
||||
|
||||
{/* Telegram-specific */}
|
||||
{ch === "telegram" && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={!!chData.dm_stream} onChange={(e) => updateChannel(ch, { dm_stream: e.target.checked })} />
|
||||
|
||||
@@ -87,7 +87,7 @@ export function CronSection({ data, onSave, saving }: Props) {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("cron.maxRetriesTip")}>{t("cron.maxRetries")}</InfoLabel>
|
||||
<Input
|
||||
|
||||
@@ -56,7 +56,7 @@ function QuotaWindowInputs({
|
||||
}) {
|
||||
const { t } = useTranslation("config");
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("quota.hourTip")}>{t("quota.hour")}</InfoLabel>
|
||||
<Input
|
||||
|
||||
@@ -61,7 +61,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
|
||||
<Switch checked={draft.enabled ?? false} onCheckedChange={(v) => update({ enabled: v })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("telemetry.endpointTip")}>{t("telemetry.endpoint")}</InfoLabel>
|
||||
<Input
|
||||
@@ -84,7 +84,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("telemetry.serviceNameTip")}>{t("telemetry.serviceName")}</InfoLabel>
|
||||
<Input
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ToolsExecSection({ data, onSave, saving }: Props) {
|
||||
<CardTitle className="text-base">{t("tools.execApproval")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("tools.execSecurityTip")}>{t("tools.execSecurity")}</InfoLabel>
|
||||
<Select value={exec.security ?? "full"} onValueChange={(v) => updateNested("execApproval", { security: v })}>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ToolsProfileSection({ data, onSave, saving }: Props) {
|
||||
<CardDescription>{t("tools.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("tools.profileTip")}>{t("tools.profile")}</InfoLabel>
|
||||
<Select value={draft.profile ?? ""} onValueChange={(v) => update({ profile: v })}>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function ToolsWebSection({ data, onSave, saving }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Web Search */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>DuckDuckGo</Label>
|
||||
|
||||
@@ -145,7 +145,7 @@ export function CronDetailPage({ job, onBack, onRun, onToggle, onDelete, getRunL
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Job Info */}
|
||||
<div className="grid grid-cols-2 gap-4 rounded-md border p-4 text-sm">
|
||||
<div className="grid grid-cols-1 gap-4 rounded-md border p-4 text-sm sm:grid-cols-2">
|
||||
<InfoRow label={t("detail.infoRows.schedule")} value={formatScheduleDetail(job)} />
|
||||
{job.schedule.tz && <InfoRow label={t("detail.infoRows.timezone")} value={job.schedule.tz} />}
|
||||
{job.state?.nextRunAtMs && (
|
||||
|
||||
@@ -105,8 +105,8 @@ export function CustomToolsPage() {
|
||||
description={debouncedSearch ? t("custom.noMatchDescription") : t("custom.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("custom.columns.name")}</th>
|
||||
|
||||
@@ -148,8 +148,8 @@ export function DelegationsPage() {
|
||||
description={t("emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.sourceTarget")}</th>
|
||||
|
||||
@@ -5,7 +5,7 @@ interface LoginLayoutProps {
|
||||
|
||||
export function LoginLayout({ children, subtitle }: LoginLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-lg border bg-card p-6 shadow-sm sm:p-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">GoClaw</h1>
|
||||
|
||||
@@ -103,8 +103,8 @@ export function MCPPage() {
|
||||
description={search ? t("noMatchDescription") : t("emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
|
||||
|
||||
@@ -137,8 +137,8 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
|
||||
description={appliedQuery ? t("kg.emptySearchDescription") : t("kg.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("kg.columns.name")}</th>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-2 px-1 -mx-1 space-y-4">
|
||||
{/* Entity info */}
|
||||
{entity && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("kg.entity.externalId")}</span>{" "}
|
||||
<span className="font-mono">{entity.external_id}</span>
|
||||
@@ -111,8 +111,8 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
|
||||
) : relations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("kg.entity.noRelations")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-xs">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[400px] text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.direction")}</th>
|
||||
|
||||
@@ -185,8 +185,8 @@ export function MemoryPage() {
|
||||
description={agentId ? t("emptyAgentDescription") : t("emptyGlobalDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.path")}</th>
|
||||
|
||||
@@ -91,8 +91,8 @@ export function PendingMessagesPage() {
|
||||
description={t("emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.channel")}</th>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
export function SetupLayout({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation("setup");
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
|
||||
<div className="w-full max-w-2xl space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">GoClaw Setup</h1>
|
||||
|
||||
@@ -106,8 +106,8 @@ export function SkillsPage() {
|
||||
description={search ? t("noMatchDescription") : t("emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function TaskDetailDialog({ task, onClose }: TaskDetailDialogProps) {
|
||||
</div>
|
||||
|
||||
{/* Summary grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tasks.detail.status")}</span>{" "}
|
||||
<Badge variant={taskStatusBadgeVariant(task.status)} className="text-xs">
|
||||
|
||||
@@ -159,7 +159,7 @@ export function TtsPage() {
|
||||
</div>
|
||||
|
||||
{/* Max length & timeout */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tts-maxlen">{t("general.maxTextLength")}</Label>
|
||||
<Input
|
||||
@@ -203,7 +203,7 @@ export function TtsPage() {
|
||||
<Label htmlFor="oai-base">{t("openai.apiBase")}</Label>
|
||||
<Input id="oai-base" value={draft.openai.api_base ?? ""} onChange={(e) => updateProvider("openai", { api_base: e.target.value })} placeholder="https://api.openai.com/v1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="oai-model">{t("openai.model")}</Label>
|
||||
<Input id="oai-model" value={draft.openai.model ?? ""} onChange={(e) => updateProvider("openai", { model: e.target.value })} placeholder="gpt-4o-mini-tts" />
|
||||
@@ -226,7 +226,7 @@ export function TtsPage() {
|
||||
<Label htmlFor="el-base">{t("elevenlabs.baseUrl")}</Label>
|
||||
<Input id="el-base" value={draft.elevenlabs.base_url ?? ""} onChange={(e) => updateProvider("elevenlabs", { base_url: e.target.value })} placeholder="https://api.elevenlabs.io" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="el-voice">{t("elevenlabs.voiceId")}</Label>
|
||||
<Input id="el-voice" value={draft.elevenlabs.voice_id ?? ""} onChange={(e) => updateProvider("elevenlabs", { voice_id: e.target.value })} placeholder="pMsXgVXv3BLzUgSXRplE" />
|
||||
@@ -267,7 +267,7 @@ export function TtsPage() {
|
||||
<Label htmlFor="mm-base">{t("minimax.apiBase")}</Label>
|
||||
<Input id="mm-base" value={draft.minimax.api_base ?? ""} onChange={(e) => updateProvider("minimax", { api_base: e.target.value })} placeholder="https://api.minimax.io/v1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="mm-model">{t("minimax.model")}</Label>
|
||||
<Input id="mm-model" value={draft.minimax.model ?? ""} onChange={(e) => updateProvider("minimax", { model: e.target.value })} placeholder="speech-02-hd" />
|
||||
|
||||
Reference in new issue
Block a user