integrate thin Zopu MVP lanes (#14) #16

Merged
puter merged 21 commits from puter/issue-14-integration into feat/projects-backend 2026-07-23 21:51:01 +00:00
77 changed files with 5066 additions and 6916 deletions
Showing only changes of commit 3836d70704 - Show all commits

View File

@@ -7,7 +7,7 @@
"dev": "react-router dev",
"dev:tailscale": "react-router dev --host 0.0.0.0",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
"check-types": "react-router typegen && tsc --noEmit"
},
"dependencies": {
"@code/auth": "workspace:*",
@@ -39,7 +39,6 @@
"react-router-devtools": "^6.2.1",
"tailwindcss": "catalog:",
"typescript": "^6",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^6.1.1"
"vite": "^7.3.6"
}
}

View File

@@ -1,5 +1,4 @@
import { MessageHeader } from "@code/ui/components/message";
import { Sparkles } from "lucide-react";
import { MobileChatAssistantLabel } from "@code/ui/components/mobile-chat";
import type { AssistantResponseState } from "@/lib/chat/types";
@@ -8,20 +7,18 @@ interface AssistantIdentityProps {
}
export const AssistantIdentity = ({ state }: AssistantIdentityProps) => (
<MessageHeader className="mb-1.5 gap-2.5 px-0 text-xs font-medium text-foreground/70">
<span className="zopu-mark flex size-6 items-center justify-center rounded-lg">
<Sparkles className="size-3.5" />
</span>
Zopu
{state && (
<span className="response-state" data-state={state}>
<span aria-hidden="true" className="response-state-dots">
<i />
<i />
<i />
<MobileChatAssistantLabel
state={
state ? (
<span className="response-state" data-state={state}>
<span aria-hidden="true" className="response-state-dots">
<i />
<i />
<i />
</span>
{state === "thinking" ? "Thinking" : "Writing"}
</span>
{state === "thinking" ? "Thinking" : "Writing"}
</span>
)}
</MessageHeader>
) : undefined
}
/>
);

View File

@@ -1,10 +1,5 @@
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@code/ui/components/input-group";
import { CircleAlert, LoaderCircle, Send } from "lucide-react";
import { MobileChatComposer } from "@code/ui/components/mobile-chat";
import { LoaderCircle } from "lucide-react";
import type { ReactNode } from "react";
import { useChatComposer } from "@/hooks/chat/use-chat-composer";
@@ -14,90 +9,31 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
const busy = status === "submitted" || status === "streaming";
const composer = useChatComposer({ busy, onSend });
let statusContent: ReactNode = (
<span className="hidden sm:inline">
Enter to send · Shift + Enter for a new line
</span>
);
let statusMessage: ReactNode;
if (busy) {
statusContent = (
<>
<LoaderCircle className="size-3.5 animate-spin" />
statusMessage = (
<span className="inline-flex items-center gap-1.5">
<LoaderCircle className="size-3 animate-spin" />
Zopu is responding
</>
);
}
if (error) {
statusContent = (
<span
id="composer-error"
className="flex items-center gap-1.5 text-destructive"
>
<CircleAlert className="size-3.5" />
{error.message || "Message failed to send"}
</span>
);
}
return (
<div className="composer-dock shrink-0 pb-[env(safe-area-inset-bottom)]">
<form
aria-label="Send a message"
className="mx-auto w-full max-w-3xl px-3 pb-3 sm:px-5 sm:pb-5"
onSubmit={composer.handleSubmit}
>
<InputGroup
className={`composer-shell overflow-hidden rounded-[1.5rem] border-border/70 bg-card/95 shadow-[0_16px_50px_-18px_rgb(0_0_0/0.38)] backdrop-blur-xl transition-[border-color,box-shadow] focus-within:border-foreground/20 focus-within:shadow-[0_18px_60px_-20px_rgb(0_0_0/0.48)] dark:bg-card/90 ${
composer.isFocused
? "composer-shell-expanded"
: "composer-shell-compact"
}`}
data-disabled={busy || undefined}
>
<InputGroupTextarea
{...composer.inputProps}
aria-describedby={error ? "composer-error" : undefined}
aria-invalid={error ? true : undefined}
aria-label="Message Zopu"
className={`max-h-40 px-4 text-base leading-6 transition-[min-height,padding] duration-200 placeholder:text-muted-foreground/70 sm:px-5 ${
composer.isFocused
? "min-h-24 pb-2 pt-4"
: "min-h-14 py-[0.95rem] pr-2"
}`}
disabled={busy}
placeholder="Ask anything…"
rows={1}
/>
<InputGroupAddon
align="inline-end"
className={`shrink-0 self-end pr-2 transition-[padding] duration-200 ${
composer.isFocused ? "pb-2.5" : "pb-1.5"
}`}
>
<InputGroupButton
aria-label="Send message"
className="size-11 shrink-0 rounded-2xl bg-foreground text-background transition-all hover:scale-[1.03] hover:bg-foreground/85 active:scale-95 disabled:bg-muted disabled:text-muted-foreground sm:size-10 sm:rounded-xl"
disabled={!composer.canSend}
size="icon-sm"
type="submit"
variant="default"
>
{busy ? (
<LoaderCircle className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<div className="mt-2 flex min-h-4 items-center justify-center px-2 text-center text-[10px] text-muted-foreground/80 sm:text-[11px]">
{composer.isFocused || busy || error ? (
<span className="flex items-center gap-2">{statusContent}</span>
) : (
<span>Zopu can make mistakes. Check important information.</span>
)}
</div>
</form>
</div>
<MobileChatComposer
busy={busy}
canSend={composer.canSend}
errorMessage={
error ? error.message || "Message failed to send" : undefined
}
onSubmit={composer.handleSubmit}
statusMessage={statusMessage}
textareaProps={{
...composer.inputProps,
"aria-describedby": error ? "composer-error" : undefined,
"aria-invalid": error ? true : undefined,
disabled: busy,
}}
/>
);
};

View File

@@ -9,7 +9,7 @@ import {
} from "@code/ui/components/empty";
import { MessageGroup } from "@code/ui/components/message";
import { MessageScrollerItem } from "@code/ui/components/message-scroller";
import { Sparkles } from "lucide-react";
import { MobileChatMark } from "@code/ui/components/mobile-chat";
import { SUGGESTIONS } from "@/lib/chat/constants";
import type { ChatConversationProps } from "@/lib/chat/types";
@@ -25,23 +25,23 @@ export const ChatConversation = ({
}: ChatConversationProps) => {
if (historyReady && messages.length === 0) {
return (
<Empty className="min-h-full items-start justify-end px-1 pb-7 pt-20 sm:items-center sm:justify-center sm:px-6 sm:py-20">
<EmptyMedia className="zopu-mark size-11 rounded-2xl" variant="icon">
<Sparkles className="size-5" />
<Empty className="min-h-full items-start justify-end px-0 pt-14 pb-5">
<EmptyMedia className="size-10 bg-transparent" variant="icon">
<MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
</EmptyMedia>
<EmptyHeader className="items-start text-left sm:items-center sm:text-center">
<EmptyTitle className="text-2xl font-semibold tracking-[-0.035em] sm:text-3xl">
<EmptyHeader className="items-start text-left">
<EmptyTitle className="text-[22px] font-semibold tracking-[-0.035em]">
What can I help with?
</EmptyTitle>
<EmptyDescription className="max-w-md text-[15px] leading-6">
<EmptyDescription className="max-w-[320px] text-[14px] leading-5">
Think, write, plan, or explore an idea with Zopu.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="mt-3 grid w-full max-w-xl gap-2 sm:grid-cols-3">
<EmptyContent className="mt-3 grid w-full gap-2">
{SUGGESTIONS.map(([title, prompt]) => (
<Button
key={title}
className="group h-auto min-h-16 w-full flex-col items-start justify-center gap-0.5 rounded-2xl border-border/60 bg-card/60 px-4 py-3 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-foreground/20 hover:bg-card hover:shadow-md sm:min-h-24"
className="group h-auto min-h-14 w-full flex-col items-start justify-center gap-0.5 rounded-[16px] border-[#e7e7e4] bg-[#f7f7f5] px-4 py-3 text-left shadow-none transition-colors active:bg-[#eeeeeb]"
onClick={() => onSuggestion(prompt)}
variant="outline"
>
@@ -57,7 +57,7 @@ export const ChatConversation = ({
}
return (
<MessageGroup className="gap-8 pb-5 md:gap-10 md:pb-8">
<MessageGroup className="gap-5 pb-5">
{messages.map((message, index) => (
<MessageScrollerItem
key={message.id}

View File

@@ -1,32 +1,11 @@
import { Sparkles } from "lucide-react";
import { MobileChatHeader } from "@code/ui/components/mobile-chat";
import { MODEL_LABEL, STATUS_COPY } from "@/lib/chat/constants";
import { getStatusDotClass } from "@/lib/chat/transforms";
import { STATUS_COPY } from "@/lib/chat/constants";
import type { ChatHeaderProps } from "@/lib/chat/types";
export const ChatHeader = ({ status }: ChatHeaderProps) => (
<header className="chat-header flex h-14 shrink-0 items-center border-b border-border/60 px-4 sm:h-16 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="zopu-mark flex size-9 shrink-0 items-center justify-center rounded-xl">
<Sparkles className="size-4" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-tight sm:text-[15px]">
Zopu
</h1>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span
aria-hidden="true"
className={`size-1.5 rounded-full ${getStatusDotClass(status)}`}
/>
<span aria-live="polite">{STATUS_COPY[status]}</span>
</p>
</div>
</div>
<p className="rounded-full border border-border/60 bg-card/50 px-2.5 py-1 text-[10px] font-medium tracking-wide text-muted-foreground sm:text-[11px]">
{MODEL_LABEL}
</p>
</div>
</header>
<MobileChatHeader
active={status !== "connecting" && status !== "error"}
statusLabel={STATUS_COPY[status]}
/>
);

View File

@@ -1,5 +1,7 @@
import { Bubble, BubbleContent } from "@code/ui/components/bubble";
import { Message, MessageContent } from "@code/ui/components/message";
import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react";
import { Streamdown } from "streamdown";
@@ -37,42 +39,26 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
);
}
const sender = isUser ? "user" : "assistant";
return (
<Message
align={isUser ? "end" : "start"}
className="chat-message text-[15px] md:text-base"
>
<MessageContent
className={
isUser
? "max-w-[88%] sm:max-w-[76%] md:max-w-[68%]"
: "max-w-full md:max-w-[92%]"
}
>
<MobileChatMessage className="chat-message" sender={sender}>
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
<Bubble
align={isUser ? "end" : "start"}
variant={isUser ? "secondary" : "ghost"}
className="max-w-full"
<MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender}
>
<BubbleContent
className={
isUser
? "rounded-[1.35rem] rounded-br-md border border-border/60 bg-secondary/80 px-4 py-2.5 leading-6 text-foreground shadow-sm whitespace-pre-wrap dark:bg-secondary/70"
: "w-full max-w-none overflow-visible leading-7 text-foreground/95"
}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
)}
{content}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
)}
{content}
</MobileChatBubble>
</div>
</MobileChatMessage>
);
};

View File

@@ -17,19 +17,19 @@ export const ChatPage = () => {
const handleSendMessage = (message: string) => agent.sendMessage(message);
return (
<section className="chat-surface flex h-full min-h-0 flex-col bg-background">
<section className="chat-surface flex h-full min-h-0 flex-col bg-[#fefefe]">
<ChatHeader status={agent.status} />
<main aria-label="Conversation" className="min-h-0 flex-1">
<MessageScrollerProvider
autoScroll
defaultScrollPosition="end"
scrollEdgeThreshold={96}
scrollMargin={24}
scrollEdgeThreshold={72}
scrollMargin={18}
>
<MessageScroller>
<MessageScrollerViewport aria-label="Chat messages">
<MessageScrollerContent className="mx-auto w-full max-w-3xl px-4 pb-8 pt-7 sm:px-6 sm:pb-10 sm:pt-10">
<MessageScrollerContent className="w-full px-4 pt-4 pb-6">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
@@ -42,7 +42,7 @@ export const ChatPage = () => {
</MessageScrollerViewport>
<MessageScrollerButton
aria-label="Scroll to latest message"
className="bottom-4 size-10 rounded-full border border-border/70 bg-card/95 shadow-lg backdrop-blur"
className="bottom-3 size-9 rounded-full border border-[#dededb] bg-[#fefefe]/95 shadow-sm backdrop-blur"
direction="end"
/>
</MessageScroller>

View File

@@ -1,16 +1,21 @@
import { Message, MessageContent } from "@code/ui/components/message";
import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import { AssistantIdentity } from "./assistant-identity";
export const ChatThinkingResponse = () => (
<Message align="start" className="chat-message">
<MessageContent className="max-w-full md:max-w-[92%]">
<MobileChatMessage className="chat-message" sender="assistant">
<div className="w-full min-w-0">
<AssistantIdentity state="thinking" />
<div className="thinking-line" aria-label="Zopu is thinking">
<span />
<span />
<span />
</div>
</MessageContent>
</Message>
<MobileChatBubble sender="assistant">
<div className="thinking-line" aria-label="Zopu is thinking">
<span />
<span />
<span />
</div>
</MobileChatBubble>
</div>
</MobileChatMessage>
);

View File

@@ -1,4 +1,5 @@
import { Check, LoaderCircle, Terminal, TriangleAlert } from "lucide-react";
import { MobileChatToolCall } from "@code/ui/components/mobile-chat";
import { Search, SquareTerminal } from "lucide-react";
import type { ChatToolCallProps } from "@/lib/chat/types";
@@ -7,35 +8,37 @@ export const ChatToolCall = ({ part }: ChatToolCallProps) => {
typeof part.input === "string"
? part.input
: JSON.stringify(part.input, null, 2);
let Icon = LoaderCircle;
let status = "Running";
let status = "running";
let tone: "error" | "neutral" | "success" = "neutral";
if (part.state === "output-available") {
detail =
typeof part.output === "string"
? part.output
: JSON.stringify(part.output, null, 2);
Icon = Check;
status = "Completed";
status = "done";
tone = "success";
} else if (part.state === "output-error") {
detail = part.errorText;
Icon = TriangleAlert;
status = "Failed";
status = "failed";
tone = "error";
}
const isRunning = part.state === "input-available";
const isSearch = part.toolName.toLowerCase().includes("search");
return (
<details className="group/tool overflow-hidden rounded-xl border border-border/60 bg-card/45 text-xs">
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-muted-foreground marker:hidden">
<Terminal className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85">
{part.toolName}
</span>
<span>{status}</span>
<Icon className={`size-3.5 ${isRunning ? "animate-spin" : ""}`} />
</summary>
<pre className="max-h-72 overflow-auto border-t border-border/50 bg-background/60 px-3 py-2 font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground">
{detail}
</pre>
</details>
<MobileChatToolCall
detail={detail}
icon={
isSearch ? (
<Search className="size-3" strokeWidth={2.2} />
) : (
<SquareTerminal className="size-3" strokeWidth={2.2} />
)
}
status={status}
tone={tone}
toolName={part.toolName}
/>
);
};

View File

@@ -7,48 +7,6 @@ body {
overflow: hidden;
}
.chat-surface {
position: relative;
isolation: isolate;
background:
radial-gradient(
circle at 50% -12%,
oklch(0.72 0.045 255 / 9%),
transparent 32rem
),
var(--background);
}
.chat-surface::before {
position: absolute;
z-index: -1;
inset: 0;
background-image: radial-gradient(
oklch(0.5 0 0 / 9%) 0.55px,
transparent 0.55px
);
background-size: 18px 18px;
mask-image: linear-gradient(to bottom, black, transparent 42%);
content: "";
pointer-events: none;
}
.chat-header {
background: color-mix(in oklch, var(--background) 88%, transparent);
backdrop-filter: blur(18px) saturate(1.25);
}
.zopu-mark {
border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent);
background:
linear-gradient(145deg, oklch(1 0 0 / 65%), transparent 55%),
color-mix(in oklch, var(--muted) 88%, var(--background));
color: var(--foreground);
box-shadow:
inset 0 1px 0 oklch(1 0 0 / 48%),
0 6px 18px -12px oklch(0 0 0 / 55%);
}
.chat-message {
animation: chat-message-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@@ -120,15 +78,6 @@ body {
}
}
.composer-dock {
position: relative;
background: linear-gradient(to top, var(--background) 68%, transparent);
}
.composer-shell {
transform: translateZ(0);
}
@keyframes chat-message-enter {
from {
opacity: 0;

View File

@@ -1,5 +1,6 @@
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";
@@ -76,7 +77,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
<Meta />
<Links />
</head>
<body>
<body className="bg-[#11110f]">
{children}
<ScrollRestoration />
<Scripts />
@@ -89,13 +90,14 @@ const App = () => (
<WebAuthProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
defaultTheme="light"
forcedTheme="light"
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<div className="h-svh min-h-0 overflow-hidden">
<MobileViewport>
<Outlet />
</div>
</MobileViewport>
<Toaster richColors />
</ThemeProvider>
</WebAuthProvider>

View File

@@ -0,0 +1,66 @@
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" />
<main className="min-h-0 flex-1 overflow-y-auto px-4 pt-4 pb-6">
<MobileChatMessage sender="user">
<MobileChatBubble sender="user">
Summarize Q3 and find recent AI news.
</MobileChatBubble>
</MobileChatMessage>
<MobileChatMessage className="mt-5" sender="assistant">
<div className="w-full min-w-0">
<MobileChatAssistantLabel />
<MobileChatBubble sender="assistant">
<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>
<p>
Revenue grew 18% QoQ to $4.2M. Top risks: customer concentration,
sales cycles, and infra costs.
</p>
</MobileChatBubble>
</div>
</MobileChatMessage>
</main>
<MobileChatComposer
canSend
onSubmit={(event) => event.preventDefault()}
textareaProps={{}}
/>
</section>
);
export default MobileChatShowcase;

View File

@@ -3,9 +3,11 @@ import path from "node:path";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite-plus";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
plugins: [tailwindcss(), reactRouter()],
resolve: {
tsconfigPaths: true,
},
});

View File

@@ -137,7 +137,6 @@
"tailwindcss": "catalog:",
"typescript": "^6",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^6.1.1",
},
},
"packages/agents": {
@@ -2381,8 +2380,6 @@
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
"goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="],
"google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
@@ -3557,8 +3554,6 @@
"ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
"tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -3671,8 +3666,6 @@
"vite-plus": ["vite-plus@0.2.2", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@oxlint/plugins": "=1.68.0", "@vitest/browser": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "@voidzero-dev/vite-plus-core": "0.2.2", "oxfmt": "=0.57.0", "oxlint": "=1.72.0", "oxlint-tsgolint": "=0.24.0", "vitest": "4.1.9" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.2.2", "@voidzero-dev/vite-plus-darwin-x64": "0.2.2", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.2.2", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.2.2", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.2.2", "@voidzero-dev/vite-plus-linux-x64-musl": "0.2.2", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.2.2", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.2.2" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.9", "@vitest/browser-webdriverio": "4.1.9" }, "optionalPeers": ["@vitest/browser-playwright", "@vitest/browser-webdriverio"], "bin": { "vp": "bin/vp", "vpr": "bin/vpr", "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint" } }, "sha512-bXO3O0F2/uxtvX9Ck0o67stTErH/Zh0GEcCMd9pAh22tTHABCNTDPrRMWVo733e7Ux3h0Y7HanJ7neOV/nid4g=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
"vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
"vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="],

View File

@@ -0,0 +1,334 @@
import { cn } from "@code/ui/lib/utils";
import { ArrowUp, GripVertical, LoaderCircle } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
const MobileViewport = ({ className, ...props }: ComponentProps<"div">) => (
<div
data-slot="mobile-viewport"
className={cn(
"relative mx-auto h-[100dvh] min-h-0 w-full max-w-[390px] overflow-hidden bg-background text-foreground sm:border-x sm:border-border/70",
className
)}
{...props}
/>
);
const MobileChatMark = ({ className, ...props }: ComponentProps<"span">) => (
<span
aria-hidden="true"
data-slot="mobile-chat-mark"
className={cn(
"inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-[#f1f1ef] text-[#171716]",
className
)}
{...props}
>
<GripVertical className="size-3.5" strokeWidth={2.2} />
</span>
);
interface MobileChatHeaderProps extends Omit<
ComponentProps<"header">,
"children"
> {
active?: boolean;
label?: string;
statusLabel: string;
}
const MobileChatHeader = ({
active = false,
className,
label = "Zopu",
statusLabel,
...props
}: MobileChatHeaderProps) => (
<header
data-slot="mobile-chat-header"
className={cn(
"flex h-[78px] shrink-0 items-center border-b border-[#e9e9e7] bg-[#fefefe] px-4",
className
)}
{...props}
>
<div className="flex w-full items-center gap-3">
<MobileChatMark className="size-7 rounded-[7px]" />
<h1 className="text-[17px] leading-none font-semibold tracking-[-0.015em] text-[#171716]">
{label}
</h1>
<span
aria-hidden="true"
className={cn(
"ml-auto size-1.5 rounded-full bg-[#b9bbb8]",
active && "bg-[#17c777]"
)}
/>
<span className="sr-only" aria-live="polite">
{statusLabel}
</span>
</div>
</header>
);
interface MobileChatMessageProps extends Omit<
ComponentProps<"article">,
"role"
> {
sender: "assistant" | "user";
}
const MobileChatMessage = ({
className,
sender,
...props
}: MobileChatMessageProps) => (
<article
data-sender={sender}
data-slot="mobile-chat-message"
className={cn(
"flex w-full min-w-0",
sender === "user" ? "justify-end" : "justify-start",
className
)}
{...props}
/>
);
interface MobileChatBubbleProps extends Omit<ComponentProps<"div">, "role"> {
sender: "assistant" | "user";
}
const MobileChatBubble = ({
className,
sender,
...props
}: MobileChatBubbleProps) => (
<div
data-sender={sender}
data-slot="mobile-chat-bubble"
className={cn(
"min-w-0 text-[15px] tracking-[-0.012em] wrap-break-word",
sender === "user"
? "max-w-[260px] rounded-[20px] bg-[#0d0d0c] px-[14px] py-[11px] leading-[18px] text-[#fafafa]"
: "w-full leading-[18px] text-[#232321]",
className
)}
{...props}
/>
);
interface MobileChatAssistantLabelProps extends ComponentProps<"div"> {
label?: string;
state?: ReactNode;
}
const MobileChatAssistantLabel = ({
className,
label = "Zopu",
state,
...props
}: MobileChatAssistantLabelProps) => (
<div
data-slot="mobile-chat-assistant-label"
className={cn(
"mb-3 flex items-center gap-2 text-[14px] leading-5 text-[#6f706e]",
className
)}
{...props}
>
<MobileChatMark className="size-5 rounded-[5px] [&_svg]:size-3" />
<span>{label}</span>
{state}
</div>
);
interface MobileChatToolCallProps extends ComponentProps<"div"> {
detail?: ReactNode;
icon?: ReactNode;
status: string;
tone?: "error" | "neutral" | "success";
toolName: string;
}
const MobileChatToolCall = ({
children,
className,
detail,
icon,
status,
tone = "neutral",
toolName,
...props
}: MobileChatToolCallProps) => {
let content: ReactNode;
if (children) {
content = <div className="mt-2 grid gap-2">{children}</div>;
} else if (detail) {
content = (
<div className="mt-2 flex items-start gap-2">
<MobileChatMark className="mt-0.5 size-4 rounded-[4px] [&_svg]:size-2.5" />
<p className="line-clamp-2 min-w-0 text-[11px] leading-[15px] text-[#555653]">
{detail}
</p>
</div>
);
}
return (
<div
data-slot="mobile-chat-tool-call"
className={cn(
"mb-3 overflow-hidden rounded-[16px] bg-[#f4f4f2] px-3 py-3 text-[#171716]",
className
)}
{...props}
>
<div className="flex min-h-5 items-center gap-2">
<span className="flex size-5 shrink-0 items-center justify-center rounded-[5px] bg-[#e8e8e5] text-[#171716]">
{icon ?? <GripVertical className="size-3" strokeWidth={2.2} />}
</span>
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 font-semibold">
{toolName}
</span>
<span
className={cn(
"rounded-full bg-[#e6e7e4] px-2 py-1 text-[9px] leading-none font-medium text-[#454643]",
tone === "success" && "bg-[#dff5e8] text-[#174b32]",
tone === "error" && "bg-[#f8dfdc] text-[#7b2821]"
)}
>
{status}
</span>
</div>
{content}
</div>
);
};
interface MobileChatToolResultProps extends ComponentProps<"div"> {
icon?: ReactNode;
source?: string;
title: string;
}
const MobileChatToolResult = ({
className,
icon,
source,
title,
...props
}: MobileChatToolResultProps) => (
<div
data-slot="mobile-chat-tool-result"
className={cn("flex min-w-0 items-start gap-2", className)}
{...props}
>
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-[#e8e8e5] text-[#171716]">
{icon ?? <GripVertical className="size-2.5" strokeWidth={2.2} />}
</span>
<span className="min-w-0">
<span className="block truncate text-[11px] leading-[14px] font-medium text-[#292a28]">
{title}
</span>
{source ? (
<span className="block truncate text-[9px] leading-[12px] text-[#9a9b98]">
{source}
</span>
) : null}
</span>
</div>
);
interface MobileChatComposerProps extends Omit<
ComponentProps<"form">,
"children"
> {
busy?: boolean;
canSend: boolean;
errorMessage?: string;
statusMessage?: ReactNode;
textareaProps: ComponentProps<"textarea">;
}
const MobileChatComposer = ({
busy = false,
canSend,
className,
errorMessage,
statusMessage,
textareaProps,
...props
}: MobileChatComposerProps) => {
const { className: textareaClassName, ...inputProps } = textareaProps;
const message = errorMessage ?? statusMessage;
return (
<div
data-slot="mobile-chat-composer-dock"
className="shrink-0 border-t border-[#e8e8e6] bg-[#fefefe] pb-[env(safe-area-inset-bottom)]"
>
<form
aria-label="Send a message"
className={cn("w-full px-3 pt-[11px] pb-3", className)}
{...props}
>
{message ? (
<div
id={errorMessage ? "composer-error" : undefined}
className={cn(
"mb-2 px-3 text-center text-[10px] leading-4 text-[#777875]",
errorMessage && "text-destructive"
)}
>
{message}
</div>
) : null}
<div className="flex items-end gap-2.5">
<textarea
aria-label="Message Zopu"
placeholder="Message Zopu..."
rows={1}
className={cn(
"min-h-10 max-h-28 flex-1 resize-none rounded-[20px] border-0 bg-[#f4f4f2] px-4 py-[10px] text-[15px] leading-5 text-[#232321] outline-none placeholder:text-center placeholder:text-[#b6b7b4] focus-visible:ring-2 focus-visible:ring-[#171716]/15 disabled:cursor-not-allowed disabled:opacity-60",
textareaClassName
)}
{...inputProps}
/>
<button
aria-label="Send message"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0c] text-[#fafafa] transition-transform active:scale-[0.96] disabled:bg-[#dededb] disabled:text-[#9b9c99]"
disabled={!canSend}
type="submit"
>
{busy ? (
<LoaderCircle className="size-[18px] animate-spin" />
) : (
<ArrowUp className="size-[18px]" strokeWidth={2.2} />
)}
</button>
</div>
</form>
</div>
);
};
export {
MobileChatAssistantLabel,
MobileChatBubble,
MobileChatComposer,
MobileChatHeader,
MobileChatMark,
MobileChatMessage,
MobileChatToolResult,
MobileChatToolCall,
MobileViewport,
};
export type {
MobileChatAssistantLabelProps,
MobileChatBubbleProps,
MobileChatComposerProps,
MobileChatHeaderProps,
MobileChatMessageProps,
MobileChatToolCallProps,
MobileChatToolResultProps,
};