mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
fix(frontend): improve mobile workspace layout (#3646)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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] && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user