fix(frontend): improve mobile workspace layout (#3646)

This commit is contained in:
Huixin615
2026-06-19 11:14:49 +08:00
committed by GitHub
parent 6044e5c553
commit 86a4744941
6 changed files with 94 additions and 45 deletions
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
@@ -168,33 +169,36 @@ export default function AgentChatPage() {
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4",
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<SidebarTrigger className="md:hidden" />
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<div className="flex min-w-0 shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
<span className="hidden max-w-24 truncate text-xs font-medium sm:inline sm:max-w-none">
{agent?.name ?? agent_name}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<div className="flex shrink-0 items-center sm:mr-4">
<Tooltip content={t.agents.newChat}>
<Button
className="px-2 sm:px-3"
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_name}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
<PlusSquare />
<span className="hidden sm:inline">{t.agents.newChat}</span>
</Button>
</Tooltip>
<TokenUsageIndicator
@@ -229,14 +233,15 @@ export default function AgentChatPage() {
<div
className={cn(
"right-0 bottom-0 left-0 z-30 flex justify-center px-4",
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
)}
>
<div
className={cn(
"relative w-full",
isWelcomeMode && "-translate-y-[calc(50vh-96px)]",
isWelcomeMode &&
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
@@ -267,7 +272,7 @@ export default function AgentChatPage() {
<InputBox
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-4",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ChatBox,
@@ -170,16 +171,17 @@ export default function ChatPage() {
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4",
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<SidebarTrigger className="md:hidden" />
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<TokenUsageIndicator
threadId={isNewThread ? undefined : threadId}
backendUsage={backendTokenUsage}
@@ -210,14 +212,15 @@ export default function ChatPage() {
</div>
<div
className={cn(
"right-0 bottom-0 left-0 z-30 flex justify-center px-4",
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
)}
>
<div
className={cn(
"relative w-full",
isWelcomeMode && "-translate-y-[calc(50vh-96px)]",
isWelcomeMode &&
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
@@ -248,7 +251,7 @@ export default function ChatPage() {
<InputBox
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-4",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
@@ -280,7 +283,7 @@ export default function ChatPage() {
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full rounded-2xl",
isWelcomeMode && "-translate-y-4",
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
)}
/>
)}
@@ -582,7 +582,7 @@ export function InputBox({
<div
ref={promptRootRef}
className={cn(
"relative flex flex-col",
"relative flex min-w-0 flex-col",
isWelcomeMode ? "gap-4" : "gap-2",
)}
>
@@ -694,8 +694,8 @@ export function InputBox({
ref={textareaRef}
/>
</PromptInputBody>
<PromptInputFooter className="flex">
<PromptInputTools>
<PromptInputFooter className="flex flex-wrap gap-2 sm:flex-nowrap">
<PromptInputTools className="min-w-0 flex-1 flex-wrap">
{/* TODO: Add more connectors here
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" />
@@ -717,7 +717,7 @@ export function InputBox({
: "flash"
}
>
<PromptInputActionMenuTrigger className="gap-1! px-2!">
<PromptInputActionMenuTrigger className="max-w-28 gap-1! px-2! sm:max-w-none">
<div>
{context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && (
@@ -732,7 +732,7 @@ export function InputBox({
</div>
<div
className={cn(
"text-xs font-normal",
"truncate text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
)}
>
@@ -879,7 +879,7 @@ export function InputBox({
</PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="gap-1! px-2!">
<PromptInputActionMenuTrigger className="hidden gap-1! px-2! sm:inline-flex">
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
@@ -994,13 +994,13 @@ export function InputBox({
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools>
<PromptInputTools className="min-w-0 justify-end">
<ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
<ModelSelectorTrigger asChild>
<PromptInputButton>
<PromptInputButton className="max-w-40 min-w-0 sm:max-w-56">
<div className="flex min-w-0 flex-col items-start text-left">
<ModelSelectorName className="text-xs font-normal">
{selectedModel?.display_name}
@@ -1103,7 +1103,7 @@ function SuggestionList() {
[textInput],
);
return (
<Suggestions className="min-h-16 w-fit items-start">
<Suggestions className="min-h-16 w-full max-w-full justify-center px-4 sm:w-fit sm:px-0">
<ConfettiButton
className="text-muted-foreground cursor-pointer rounded-full px-4 text-xs font-normal"
variant="outline"
+17 -19
View File
@@ -10,6 +10,14 @@ import { AuroraText } from "../ui/aurora-text";
let waved = false;
function WelcomeDescription({ children }: { children: string }) {
return (
<p className="max-w-full text-wrap break-words whitespace-pre-line">
{children}
</p>
);
}
export function Welcome({
className,
mode,
@@ -32,15 +40,15 @@ export function Welcome({
return (
<div
className={cn(
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center",
"mx-auto flex w-full max-w-full flex-col items-center justify-center gap-2 px-4 py-4 text-center sm:px-8",
className,
)}
>
<div className="text-2xl font-bold">
<div className="max-w-full text-2xl font-bold">
{searchParams.get("mode") === "skill" ? (
`${t.welcome.createYourOwnSkill}`
) : (
<div className="flex items-center gap-2">
<div className="flex max-w-full flex-wrap items-center justify-center gap-2">
<div className={cn("inline-block", !waved ? "animate-wave" : "")}>
{isUltra ? "🚀" : "👋"}
</div>
@@ -49,24 +57,14 @@ export function Welcome({
)}
</div>
{searchParams.get("mode") === "skill" ? (
<div className="text-muted-foreground text-sm">
{t.welcome.createYourOwnSkillDescription.includes("\n") ? (
<pre className="font-sans whitespace-pre">
{t.welcome.createYourOwnSkillDescription}
</pre>
) : (
<p>{t.welcome.createYourOwnSkillDescription}</p>
)}
<div className="text-muted-foreground max-w-full text-sm">
<WelcomeDescription>
{t.welcome.createYourOwnSkillDescription}
</WelcomeDescription>
</div>
) : (
<div className="text-muted-foreground text-sm">
{t.welcome.description.includes("\n") ? (
<pre className="font-sans whitespace-pre">
{t.welcome.description}
</pre>
) : (
<p>{t.welcome.description}</p>
)}
<div className="text-muted-foreground max-w-full text-sm">
<WelcomeDescription>{t.welcome.description}</WelcomeDescription>
</div>
)}
</div>
@@ -12,6 +12,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
@@ -51,7 +52,8 @@ export function WorkspaceHeader({
)}
{...props}
>
<div className="flex items-center gap-2 px-4">
<div className="flex min-w-0 items-center gap-2 px-2 sm:px-4">
<SidebarTrigger className="md:hidden" />
<Breadcrumb>
<BreadcrumbList>
{segments?.[0] && (
+41
View File
@@ -29,4 +29,45 @@ test.describe("Sidebar navigation", () => {
await page.waitForURL("**/workspace/agents");
await expect(page).toHaveURL(/\/workspace\/agents/);
});
test("mobile welcome layout stays within viewport and opens sidebar", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
mockLangGraphAPI(page);
await page.goto("/workspace/chats/new");
const viewportWidth = page.viewportSize()?.width ?? 390;
const expectInsideViewport = async (
locator: ReturnType<typeof page.locator>,
) => {
await expect(locator).toBeVisible({ timeout: 15_000 });
const box = await locator.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(-1);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewportWidth + 1);
};
await expectInsideViewport(page.getByText(/Welcome to|欢迎使用/).first());
await expectInsideViewport(page.getByRole("textbox").first());
await expectInsideViewport(page.locator("[data-slot='suggestions-list']"));
const mobileSidebarTrigger = page
.locator("[data-sidebar='trigger']:visible")
.first();
await expect(mobileSidebarTrigger).toBeVisible();
await mobileSidebarTrigger.click();
const mobileSidebar = page.locator(
"[data-mobile='true'][data-sidebar='sidebar']",
);
await expect(mobileSidebar).toBeVisible();
await expect(
mobileSidebar.locator("a[href='/workspace/chats']"),
).toBeVisible();
await expect(
mobileSidebar.locator("a[href='/workspace/agents']"),
).toBeVisible();
});
});