build mobile workspace flow

This commit is contained in:
sai karthik
2026-07-23 20:05:48 +05:30
parent 732c458fd4
commit 2aa7150716
35 changed files with 2552 additions and 217 deletions

View File

@@ -0,0 +1,66 @@
import {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkListScreen,
MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product";
import { useNavigate } from "react-router";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
export type MobileFlowScreen =
| "assistant-chat"
| "home"
| "work-list"
| "work-unit-detail";
interface MobileFlowPageProps {
publicDemo?: boolean;
screen: MobileFlowScreen;
}
export const MobileFlowPage = ({
publicDemo = false,
screen,
}: MobileFlowPageProps) => {
const navigate = useNavigate();
const workspace = useMobileWorkspace();
const homePath = publicDemo ? "/design" : "/";
const workPath = publicDemo ? "/design/work" : "/work";
const chatPath = publicDemo ? "/design/chat" : "/chat";
const detailPath = publicDemo ? "/design/work/flow-08" : "/work/flow-08";
const handleOpenUnit = () => {
if (screen === "work-list" && !workspace.expanded) {
workspace.setExpanded(true);
return;
}
navigate(detailPath);
};
const screenProps = {
composerValue: workspace.composerValue,
onBack: () => navigate(homePath),
onComposerChange: workspace.handleComposerChange,
onComposerSubmit: workspace.handleComposerSubmit,
onOpenAssistant: () => navigate(chatPath),
onOpenUnit: handleOpenUnit,
onViewWork: () => navigate(workPath),
statusMessage: workspace.statusMessage,
};
if (screen === "assistant-chat") {
return <MobileAssistantChatScreen {...screenProps} />;
}
if (screen === "home") {
return <MobileHomeScreen {...screenProps} />;
}
if (screen === "work-unit-detail") {
return <MobileWorkUnitDetailScreen {...screenProps} />;
}
if (workspace.expanded) {
return <MobileExpandedWorkScreen {...screenProps} />;
}
return <MobileWorkListScreen {...screenProps} />;
};

View File

@@ -0,0 +1,35 @@
import type { FormEvent } from "react";
import { useState } from "react";
export const useMobileWorkspace = (initialExpanded = false) => {
const [activeIndex, setActiveIndex] = useState(0);
const [composerValue, setComposerValue] = useState("");
const [expanded, setExpanded] = useState(initialExpanded);
const [statusMessage, setStatusMessage] = useState<string>();
const handleComposerChange = (value: string) => {
setComposerValue(value);
setStatusMessage(undefined);
};
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const message = composerValue.trim();
if (!message) {
return;
}
setComposerValue("");
setStatusMessage("Added to Zopus queue");
};
return {
activeIndex,
composerValue,
expanded,
handleActiveIndexChange: setActiveIndex,
handleComposerChange,
handleComposerSubmit,
setExpanded,
statusMessage,
};
};

View File

@@ -4,7 +4,6 @@
html,
body {
min-height: 100%;
overflow: hidden;
}
.ai-conversation-mobile > div {

View File

@@ -1,6 +1,5 @@
import { WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web";
import { MobileViewport } from "@code/ui/components/mobile-chat";
import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react";
@@ -95,9 +94,7 @@ const App = () => (
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<MobileViewport>
<Outlet />
</MobileViewport>
<Outlet />
<Toaster richColors />
</ThemeProvider>
</WebAuthProvider>

View File

@@ -1,4 +1,25 @@
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
import { index, layout, route } from "@react-router/dev/routes";
import type { RouteConfig } from "@react-router/dev/routes";
export default flatRoutes() satisfies RouteConfig;
export default [
layout("./routes/auth/layout.tsx", [
route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"),
]),
layout("./routes/app/layout.tsx", [
layout("./routes/app/mobile/layout.tsx", [
index("./routes/app/mobile/page.tsx"),
route("chat", "./routes/app/mobile/chat/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]),
route("dashboard", "./routes/app/dashboard/page.tsx"),
route("todos", "./routes/app/todos/page.tsx"),
]),
route("design", "./routes/design/layout.tsx", [
index("./routes/design/page.tsx"),
route("chat", "./routes/design/chat/page.tsx"),
route("work", "./routes/design/work/page.tsx"),
route("work/:workUnitId", "./routes/design/work-unit/page.tsx"),
]),
] satisfies RouteConfig;

View File

@@ -1,12 +0,0 @@
import { ChatPage } from "@/components/chat/chat-page";
import type { Route } from "./+types/_app._index";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
const Home = () => <ChatPage />;
export default Home;

View File

@@ -1,102 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import { Checkbox } from "@code/ui/components/checkbox";
import { Input } from "@code/ui/components/input";
import { useMutation, useQuery } from "convex/react";
import { Loader2, Trash2 } from "lucide-react";
import { useState, type FormEvent } from "react";
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = newTodoText.trim();
if (!text) return;
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
toggleTodo({ id, completed: !currentCompleted });
};
const handleDeleteTodo = (id: Id<"todos">) => {
deleteTodo({ id });
};
return (
<div className="w-full mx-auto max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleAddTodo} className="mb-6 flex items-center space-x-2">
<Input
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..."
/>
<Button type="submit" disabled={!newTodoText.trim()}>
Add
</Button>
</form>
{todos === undefined ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p>
) : (
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo._id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() => handleToggleTodo(todo._id, todo.completed)}
id={`todo-${todo._id}`}
/>
<label
htmlFor={`todo-${todo._id}`}
className={`${todo.completed ? "line-through text-muted-foreground" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo._id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -6,7 +6,11 @@ export default function AppLayout() {
const location = useLocation();
if (isLoading) {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading</div>;
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (!isAuthenticated) {

View File

@@ -0,0 +1,12 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
export default function MobileChatPage() {
return <MobileFlowPage screen="assistant-chat" />;
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from "react-router";
export default function MobileProductLayout() {
return (
<div className="min-h-svh bg-[#11110f]">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Daily focus | Zopu" },
{
content: "Supervise active work and open the next action",
name: "description",
},
];
export default function MobileHomePage() {
return <MobileFlowPage screen="home" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Active work | Zopu" },
{
content: "Browse active work and expand a work unit in place",
name: "description",
},
];
export default function MobileWorkListPage() {
return <MobileFlowPage screen="work-list" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Work unit | Zopu" },
{
content: "Review the state, next milestone, and activity for a work unit",
name: "description",
},
];
export default function MobileWorkUnitPage() {
return <MobileFlowPage screen="work-unit-detail" />;
}

View File

@@ -0,0 +1,123 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import { Checkbox } from "@code/ui/components/checkbox";
import { Input } from "@code/ui/components/input";
import { useMutation, useQuery } from "convex/react";
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import type { FormEvent, ReactNode } from "react";
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = newTodoText.trim();
if (!text) {
return;
}
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
void toggleTodo({ completed: !currentCompleted, id });
};
const handleDeleteTodo = (id: Id<"todos">) => {
void deleteTodo({ id });
};
let todoContent: ReactNode;
if (todos === undefined) {
todoContent = (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
} else if (todos.length === 0) {
todoContent = (
<p className="py-4 text-center">No todos yet. Add one above!</p>
);
} else {
todoContent = (
<ul className="space-y-2">
{todos.map((todo) => (
<li
className="flex items-center justify-between rounded-md border p-2"
key={todo._id}
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
id={`todo-${todo._id}`}
onCheckedChange={() =>
handleToggleTodo(todo._id, todo.completed)
}
/>
<label
className={
todo.completed
? "text-muted-foreground line-through"
: undefined
}
htmlFor={`todo-${todo._id}`}
>
{todo.text}
</label>
</div>
<Button
aria-label="Delete todo"
onClick={() => handleDeleteTodo(todo._id)}
size="icon"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
);
}
return (
<div className="mx-auto w-full max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
<form
className="mb-6 flex items-center space-x-2"
onSubmit={handleAddTodo}
>
<Input
onChange={(event) => setNewTodoText(event.target.value)}
placeholder="Add a new task..."
value={newTodoText}
/>
<Button disabled={!newTodoText.trim()} type="submit">
Add
</Button>
</form>
{todoContent}
</CardContent>
</Card>
</div>
);
}

View File

@@ -5,10 +5,16 @@ export default function AuthLayout() {
const { isAuthenticated, isLoading } = useConvexAuth();
if (isLoading) {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading</div>;
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (isAuthenticated) return <Navigate replace to="/" />;
if (isAuthenticated) {
return <Navigate replace to="/" />;
}
return (
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">

View File

@@ -1,7 +1,7 @@
import { LoginForm } from "@code/auth/web";
import { useNavigate } from "react-router";
import type { Route } from "./+types/_auth.login";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Sign in | Zopu" },

View File

@@ -1,7 +1,7 @@
import { SignupForm } from "@code/auth/web";
import { useNavigate } from "react-router";
import type { Route } from "./+types/_auth.signup";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Create account | Zopu" },

View File

@@ -1,89 +0,0 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import {
Message,
MessageContent,
} from "@code/ui/components/ai-elements/message";
import { Tool, ToolContent } from "@code/ui/components/ai-elements/tool";
import {
MobileChatAssistantLabel,
MobileChatBubble,
MobileChatComposer,
MobileChatHeader,
MobileChatMessage,
MobileChatToolCall,
MobileChatToolResult,
} from "@code/ui/components/mobile-chat";
import type { Route } from "./+types/design.mobile-chat";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Mobile chat components | Zopu" },
{
content: "Mobile component showcase for the Zopu chat experience",
name: "description",
},
];
const MobileChatShowcase = () => (
<section className="flex h-full min-h-0 flex-col bg-[#fefefe]">
<MobileChatHeader active statusLabel="Ready" />
<Conversation className="ai-conversation-mobile min-h-0 flex-1">
<ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
<Message className="max-w-full gap-0" from="user">
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
<MobileChatMessage sender="user">
<MobileChatBubble sender="user">
Summarize Q3 and find recent AI news.
</MobileChatBubble>
</MobileChatMessage>
</MessageContent>
</Message>
<Message className="mt-5 max-w-full gap-0" from="assistant">
<MessageContent className="w-full max-w-none gap-0 overflow-visible p-0">
<MobileChatMessage sender="assistant">
<div className="w-full min-w-0">
<MobileChatAssistantLabel />
<MobileChatBubble sender="assistant">
<Tool className="mb-0 w-full border-0" defaultOpen>
<ToolContent className="space-y-0 p-0">
<MobileChatToolCall
status="done"
tone="success"
toolName="Web Search"
>
<MobileChatToolResult
source="news.ycombinator.com"
title="AI funding hits record highs in 2026"
/>
<MobileChatToolResult
source="news.ycombinator.com"
title="Show HN: Series B raised for new AI lab"
/>
</MobileChatToolCall>
</ToolContent>
</Tool>
<p>
Revenue grew 18% QoQ to $4.2M. Top risks: customer
concentration, sales cycles, and infra costs.
</p>
</MobileChatBubble>
</div>
</MobileChatMessage>
</MessageContent>
</Message>
</ConversationContent>
</Conversation>
<MobileChatComposer
canSend
onSubmit={(event) => event.preventDefault()}
textareaProps={{}}
/>
</section>
);
export default MobileChatShowcase;

View File

@@ -0,0 +1,12 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Chat | Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
export default function PublicMobileChatPage() {
return <MobileFlowPage publicDemo screen="assistant-chat" />;
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from "react-router";
export default function DesignLayout() {
return (
<div className="min-h-svh bg-[#11110f]">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{
content: "Review active work and open the next action",
name: "description",
},
];
export default function PublicMobileHomePage() {
return <MobileFlowPage publicDemo screen="home" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Work unit | Zopu" },
{
content: "Review the state, next milestone, and activity for a work unit",
name: "description",
},
];
export default function PublicMobileWorkUnitPage() {
return <MobileFlowPage publicDemo screen="work-unit-detail" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Active work | Zopu" },
{
content: "Browse active work and expand a work unit in place",
name: "description",
},
];
export default function PublicMobileWorkPage() {
return <MobileFlowPage publicDemo screen="work-list" />;
}