diff --git a/apps/web/package.json b/apps/web/package.json index 526e014..0a57a70 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } } diff --git a/apps/web/src/components/chat/assistant-identity.tsx b/apps/web/src/components/chat/assistant-identity.tsx index 05f6314..47038ef 100644 --- a/apps/web/src/components/chat/assistant-identity.tsx +++ b/apps/web/src/components/chat/assistant-identity.tsx @@ -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) => ( - - - - - Zopu - {state && ( - - - - - + + + + + + + {state === "thinking" ? "Thinking" : "Writing"} - {state === "thinking" ? "Thinking" : "Writing"} - - )} - + ) : undefined + } + /> ); diff --git a/apps/web/src/components/chat/chat-composer.tsx b/apps/web/src/components/chat/chat-composer.tsx index a68544e..1b2466a 100644 --- a/apps/web/src/components/chat/chat-composer.tsx +++ b/apps/web/src/components/chat/chat-composer.tsx @@ -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 = ( - - Enter to send · Shift + Enter for a new line - - ); + let statusMessage: ReactNode; if (busy) { - statusContent = ( - <> - + statusMessage = ( + + Zopu is responding - > - ); - } - if (error) { - statusContent = ( - - - {error.message || "Message failed to send"} ); } return ( - - - - - - - {busy ? ( - - ) : ( - - )} - - - - - {composer.isFocused || busy || error ? ( - {statusContent} - ) : ( - Zopu can make mistakes. Check important information. - )} - - - + ); }; diff --git a/apps/web/src/components/chat/chat-conversation.tsx b/apps/web/src/components/chat/chat-conversation.tsx index 686e675..9763572 100644 --- a/apps/web/src/components/chat/chat-conversation.tsx +++ b/apps/web/src/components/chat/chat-conversation.tsx @@ -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 ( - - - + + + - - + + What can I help with? - + Think, write, plan, or explore an idea with Zopu. - + {SUGGESTIONS.map(([title, prompt]) => ( onSuggestion(prompt)} variant="outline" > @@ -57,7 +57,7 @@ export const ChatConversation = ({ } return ( - + {messages.map((message, index) => ( ( - - - - - - - - - Zopu - - - - {STATUS_COPY[status]} - - - - - {MODEL_LABEL} - - - + ); diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx index 71d0235..d419f88 100644 --- a/apps/web/src/components/chat/chat-message.tsx +++ b/apps/web/src/components/chat/chat-message.tsx @@ -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 ( - - + + {!isUser && ( )} - - - {message.parts.map((part) => - part.type === "dynamic-tool" ? ( - - ) : null - )} - {content} - - - - + {message.parts.map((part) => + part.type === "dynamic-tool" ? ( + + ) : null + )} + {content} + + + ); }; diff --git a/apps/web/src/components/chat/chat-page.tsx b/apps/web/src/components/chat/chat-page.tsx index 6b4ca76..270f84d 100644 --- a/apps/web/src/components/chat/chat-page.tsx +++ b/apps/web/src/components/chat/chat-page.tsx @@ -17,19 +17,19 @@ export const ChatPage = () => { const handleSendMessage = (message: string) => agent.sendMessage(message); return ( - + - + { diff --git a/apps/web/src/components/chat/chat-thinking-response.tsx b/apps/web/src/components/chat/chat-thinking-response.tsx index 47f5ba0..329459f 100644 --- a/apps/web/src/components/chat/chat-thinking-response.tsx +++ b/apps/web/src/components/chat/chat-thinking-response.tsx @@ -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 = () => ( - - + + - - - - - - - + + + + + + + + + ); diff --git a/apps/web/src/components/chat/chat-tool-call.tsx b/apps/web/src/components/chat/chat-tool-call.tsx index ab98098..b004514 100644 --- a/apps/web/src/components/chat/chat-tool-call.tsx +++ b/apps/web/src/components/chat/chat-tool-call.tsx @@ -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 ( - - - - - {part.toolName} - - {status} - - - - {detail} - - + + ) : ( + + ) + } + status={status} + tone={tone} + toolName={part.toolName} + /> ); }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d8ef689..958a4b3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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; diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index a5593cc..7bdf35b 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -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 }) => ( - + {children} @@ -89,13 +90,14 @@ const App = () => ( - + - + diff --git a/apps/web/src/routes/design.mobile-chat.tsx b/apps/web/src/routes/design.mobile-chat.tsx new file mode 100644 index 0000000..819c5ef --- /dev/null +++ b/apps/web/src/routes/design.mobile-chat.tsx @@ -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 = () => ( + + + + + + Summarize Q3 and find recent AI news. + + + + + + + + + + + + + Revenue grew 18% QoQ to $4.2M. Top risks: customer concentration, + sales cycles, and infra costs. + + + + + + + event.preventDefault()} + textareaProps={{}} + /> + +); + +export default MobileChatShowcase; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index dda6827..45ea524 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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, + }, }); diff --git a/bun.lock b/bun.lock index 6708274..77d7e6e 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/ui/src/components/mobile-chat.tsx b/packages/ui/src/components/mobile-chat.tsx new file mode 100644 index 0000000..13ee858 --- /dev/null +++ b/packages/ui/src/components/mobile-chat.tsx @@ -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">) => ( + +); + +const MobileChatMark = ({ className, ...props }: ComponentProps<"span">) => ( + + + +); + +interface MobileChatHeaderProps extends Omit< + ComponentProps<"header">, + "children" +> { + active?: boolean; + label?: string; + statusLabel: string; +} + +const MobileChatHeader = ({ + active = false, + className, + label = "Zopu", + statusLabel, + ...props +}: MobileChatHeaderProps) => ( + + + + + {label} + + + + {statusLabel} + + + +); + +interface MobileChatMessageProps extends Omit< + ComponentProps<"article">, + "role" +> { + sender: "assistant" | "user"; +} + +const MobileChatMessage = ({ + className, + sender, + ...props +}: MobileChatMessageProps) => ( + +); + +interface MobileChatBubbleProps extends Omit, "role"> { + sender: "assistant" | "user"; +} + +const MobileChatBubble = ({ + className, + sender, + ...props +}: MobileChatBubbleProps) => ( + +); + +interface MobileChatAssistantLabelProps extends ComponentProps<"div"> { + label?: string; + state?: ReactNode; +} + +const MobileChatAssistantLabel = ({ + className, + label = "Zopu", + state, + ...props +}: MobileChatAssistantLabelProps) => ( + + + {label} + {state} + +); + +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 = {children}; + } else if (detail) { + content = ( + + + + {detail} + + + ); + } + + return ( + + + + {icon ?? } + + + {toolName} + + + {status} + + + {content} + + ); +}; + +interface MobileChatToolResultProps extends ComponentProps<"div"> { + icon?: ReactNode; + source?: string; + title: string; +} + +const MobileChatToolResult = ({ + className, + icon, + source, + title, + ...props +}: MobileChatToolResultProps) => ( + + + {icon ?? } + + + + {title} + + {source ? ( + + {source} + + ) : null} + + +); + +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 ( + + + {message ? ( + + {message} + + ) : null} + + + + {busy ? ( + + ) : ( + + )} + + + + + ); +}; + +export { + MobileChatAssistantLabel, + MobileChatBubble, + MobileChatComposer, + MobileChatHeader, + MobileChatMark, + MobileChatMessage, + MobileChatToolResult, + MobileChatToolCall, + MobileViewport, +}; +export type { + MobileChatAssistantLabelProps, + MobileChatBubbleProps, + MobileChatComposerProps, + MobileChatHeaderProps, + MobileChatMessageProps, + MobileChatToolCallProps, + MobileChatToolResultProps, +};
- - {STATUS_COPY[status]} -
- {MODEL_LABEL} -
- {detail} -
+ Revenue grew 18% QoQ to $4.2M. Top risks: customer concentration, + sales cycles, and infra costs. +
+ {detail} +