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:
viettranx committed 2026-03-11 14:27:54 +07:00
1 parent ec11698fff
commit e9e0a4e813
39 files changed
+139 -68

No files matched your search

+1 -1
View File
@@ -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>
+5 -2
View File
@@ -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
+2 -2
View File
@@ -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",
)}
>
+1 -1
View File
@@ -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>
);
+3 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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} />
))}
+10 -6
View File
@@ -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]);
+28
View File
@@ -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");
};
}, []);
}
+32
View File
@@ -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>
+2 -2
View File
@@ -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>
+3 -1
View File
@@ -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"
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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">
+4 -4
View File
@@ -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" />