merge mobile workspace UI into integration
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"components": "@code/ui/components",
|
||||
"utils": "@code/ui/lib/utils",
|
||||
"ui": "@code/ui/components",
|
||||
"lib": "@/lib",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -15,90 +10,31 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
|
||||
status === "connecting" || 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" />
|
||||
{status === "connecting" ? "Preparing Zopu" : "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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} 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 +16,21 @@ 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" />
|
||||
</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">
|
||||
<ConversationEmptyState className="min-h-full items-start justify-end px-0 pt-14 pb-5 text-left">
|
||||
<MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-[22px] font-semibold tracking-[-0.035em]">
|
||||
What can I help with?
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className="max-w-md text-[15px] leading-6">
|
||||
</h2>
|
||||
<p className="max-w-[320px] text-[14px] leading-5 text-muted-foreground">
|
||||
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">
|
||||
</p>
|
||||
</div>
|
||||
<div 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"
|
||||
>
|
||||
@@ -51,27 +40,17 @@ export const ChatConversation = ({
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
</ConversationEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageGroup className="gap-8 pb-5 md:gap-10 md:pb-8">
|
||||
{messages.map((message, index) => (
|
||||
<MessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={index === messages.length - 1}
|
||||
>
|
||||
<ChatMessage message={message} />
|
||||
</MessageScrollerItem>
|
||||
<div className="flex min-w-0 flex-col gap-5 pb-5">
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
{status === "submitted" && (
|
||||
<MessageScrollerItem scrollAnchor>
|
||||
<ChatThinkingResponse />
|
||||
</MessageScrollerItem>
|
||||
)}
|
||||
</MessageGroup>
|
||||
{status === "submitted" && <ChatThinkingResponse />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Bubble, BubbleContent } from "@code/ui/components/bubble";
|
||||
import { Message, MessageContent } from "@code/ui/components/message";
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from "@code/ui/components/ai-elements/message";
|
||||
import {
|
||||
MobileChatBubble,
|
||||
MobileChatMessage,
|
||||
} from "@code/ui/components/mobile-chat";
|
||||
import type { ReactNode } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
|
||||
import type { ChatMessageProps } from "@/lib/chat/types";
|
||||
@@ -21,13 +27,13 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
|
||||
let content: ReactNode = text;
|
||||
if (!isUser) {
|
||||
content = text ? (
|
||||
<Streamdown
|
||||
<MessageResponse
|
||||
caret={isStreaming ? "block" : undefined}
|
||||
className="chat-markdown min-w-0"
|
||||
isAnimating={isStreaming}
|
||||
>
|
||||
{text}
|
||||
</Streamdown>
|
||||
</MessageResponse>
|
||||
) : (
|
||||
<div className="thinking-line" aria-label="Zopu is preparing a response">
|
||||
<span />
|
||||
@@ -37,41 +43,29 @@ 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%]"
|
||||
}
|
||||
>
|
||||
{!isUser && (
|
||||
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
|
||||
)}
|
||||
<Bubble
|
||||
align={isUser ? "end" : "start"}
|
||||
variant={isUser ? "secondary" : "ghost"}
|
||||
className="max-w-full"
|
||||
>
|
||||
<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
|
||||
<Message className="max-w-full gap-0" from={message.role}>
|
||||
<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 className="chat-message" sender={sender}>
|
||||
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
|
||||
{!isUser && (
|
||||
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
|
||||
)}
|
||||
{content}
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<MobileChatBubble
|
||||
className={isUser ? "whitespace-pre-wrap" : undefined}
|
||||
sender={sender}
|
||||
>
|
||||
{message.parts.map((part) =>
|
||||
part.type === "dynamic-tool" ? (
|
||||
<ChatToolCall key={part.toolCallId} part={part} />
|
||||
) : null
|
||||
)}
|
||||
{content}
|
||||
</MobileChatBubble>
|
||||
</div>
|
||||
</MobileChatMessage>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@code/ui/components/message-scroller";
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@code/ui/components/ai-elements/conversation";
|
||||
|
||||
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
|
||||
@@ -17,36 +15,24 @@ 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}
|
||||
>
|
||||
<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">
|
||||
<ChatConversation
|
||||
historyReady={agent.historyReady}
|
||||
messages={agent.messages}
|
||||
onSuggestion={(suggestion) =>
|
||||
void handleSendMessage(suggestion)
|
||||
}
|
||||
status={agent.status}
|
||||
/>
|
||||
</MessageScrollerContent>
|
||||
</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"
|
||||
direction="end"
|
||||
<Conversation className="ai-conversation-mobile h-full">
|
||||
<ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
|
||||
<ChatConversation
|
||||
historyReady={agent.historyReady}
|
||||
messages={agent.messages}
|
||||
onSuggestion={(suggestion) => void handleSendMessage(suggestion)}
|
||||
status={agent.status}
|
||||
/>
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton
|
||||
aria-label="Scroll to latest message"
|
||||
className="bottom-3 size-9 rounded-full border border-[#dededb] bg-[#fefefe]/95 shadow-sm backdrop-blur"
|
||||
/>
|
||||
</Conversation>
|
||||
</main>
|
||||
|
||||
<ChatComposer
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { Message, MessageContent } from "@code/ui/components/message";
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
} from "@code/ui/components/ai-elements/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%]">
|
||||
<AssistantIdentity state="thinking" />
|
||||
<div className="thinking-line" aria-label="Zopu is thinking">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<Message className="max-w-full gap-0" from="assistant">
|
||||
<MessageContent className="w-full max-w-none gap-0 overflow-visible p-0">
|
||||
<MobileChatMessage className="chat-message" sender="assistant">
|
||||
<div className="w-full min-w-0">
|
||||
<AssistantIdentity state="thinking" />
|
||||
<MobileChatBubble sender="assistant">
|
||||
<div className="thinking-line" aria-label="Zopu is thinking">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</MobileChatBubble>
|
||||
</div>
|
||||
</MobileChatMessage>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Check, LoaderCircle, Terminal, TriangleAlert } from "lucide-react";
|
||||
import {
|
||||
Tool,
|
||||
ToolContent,
|
||||
ToolInput,
|
||||
ToolOutput,
|
||||
} from "@code/ui/components/ai-elements/tool";
|
||||
import { MobileChatToolCall } from "@code/ui/components/mobile-chat";
|
||||
import { Search, SquareTerminal } from "lucide-react";
|
||||
|
||||
import type { ChatToolCallProps } from "@/lib/chat/types";
|
||||
|
||||
@@ -7,35 +14,47 @@ 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");
|
||||
const output = part.state === "output-available" ? part.output : undefined;
|
||||
const errorText = part.state === "output-error" ? part.errorText : undefined;
|
||||
|
||||
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>
|
||||
<Tool className="mb-0 w-full border-0" defaultOpen>
|
||||
<ToolContent className="space-y-0 p-0">
|
||||
<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}
|
||||
/>
|
||||
<div className="sr-only">
|
||||
<ToolInput input={part.input} />
|
||||
<ToolOutput errorText={errorText} output={output} />
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
MobileAssistantChatScreen,
|
||||
MobileExpandedWorkScreen,
|
||||
MobileHomeScreen,
|
||||
MobileWorkListScreen,
|
||||
MobileWorkUnitDetailScreen,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
|
||||
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
|
||||
|
||||
export type MobileFlowScreen =
|
||||
| "assistant-chat"
|
||||
| "home"
|
||||
| "work-list"
|
||||
| "work-unit-detail";
|
||||
|
||||
interface MobileFlowPageProps {
|
||||
screen: MobileFlowScreen;
|
||||
}
|
||||
|
||||
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { workUnitId } = useParams();
|
||||
const projectWorkspace = useMobileProjectWorkspace({
|
||||
selectedWorkUnitId: workUnitId,
|
||||
});
|
||||
const workspace = useMobileWorkspace({
|
||||
onSend: projectWorkspace.sendMessage,
|
||||
});
|
||||
const workPath = "/work";
|
||||
const chatPath = "/chat";
|
||||
|
||||
const handleOpenUnit = (selectedWorkUnitId?: string) => {
|
||||
const targetId =
|
||||
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
|
||||
if (!targetId) {
|
||||
navigate("/dashboard");
|
||||
return;
|
||||
}
|
||||
if (screen === "work-list" && !workspace.expanded) {
|
||||
workspace.setExpanded(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/work/${targetId}`);
|
||||
};
|
||||
|
||||
const screenProps = {
|
||||
composerValue: workspace.composerValue,
|
||||
data: projectWorkspace.data,
|
||||
onBack: () => navigate(workPath),
|
||||
onComposerChange: workspace.handleComposerChange,
|
||||
onComposerSubmit: workspace.handleComposerSubmit,
|
||||
onManageProjects: () => navigate("/dashboard"),
|
||||
onOpenAssistant: () => navigate(chatPath),
|
||||
onOpenUnit: handleOpenUnit,
|
||||
onViewWork: () => navigate(workPath),
|
||||
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
|
||||
};
|
||||
|
||||
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} />;
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useFlueAgent } from "@flue/react";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
||||
import type { ChatAgentState } from "@/lib/chat/types";
|
||||
|
||||
export const useChatAgent = (): ChatAgentState => {
|
||||
// The agent session is not created until the authenticated user's personal
|
||||
// organization has been ensured and its id is available.
|
||||
const organization = usePersonalOrganization();
|
||||
export const useOrganizationChatAgent = (
|
||||
organization: PersonalOrganizationState
|
||||
): ChatAgentState => {
|
||||
const agent = useFlueAgent({
|
||||
...CHAT_AGENT,
|
||||
id: organization.organizationId,
|
||||
@@ -36,3 +36,6 @@ export const useChatAgent = (): ChatAgentState => {
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatAgent = (): ChatAgentState =>
|
||||
useOrganizationChatAgent(usePersonalOrganization());
|
||||
|
||||
83
apps/web/src/hooks/use-mobile-project-workspace.ts
Normal file
83
apps/web/src/hooks/use-mobile-project-workspace.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type {
|
||||
MobileAssistantMessageView,
|
||||
MobileAssistantView,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import { useQuery } from "convex/react";
|
||||
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
|
||||
import { STATUS_COPY } from "@/lib/chat/constants";
|
||||
import { getMessageText } from "@/lib/chat/transforms";
|
||||
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
|
||||
|
||||
const toAssistantMessages = (
|
||||
messages: ReturnType<typeof useOrganizationChatAgent>["messages"]
|
||||
): MobileAssistantMessageView[] =>
|
||||
messages.flatMap((message) => {
|
||||
if (message.role !== "assistant" && message.role !== "user") {
|
||||
return [];
|
||||
}
|
||||
const text = getMessageText(message);
|
||||
return text
|
||||
? [
|
||||
{
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
const getStatusTone = (
|
||||
status: ReturnType<typeof useOrganizationChatAgent>["status"]
|
||||
): MobileAssistantView["statusTone"] => {
|
||||
if (status === "error") {
|
||||
return "red";
|
||||
}
|
||||
return status === "connecting" ? "amber" : "green";
|
||||
};
|
||||
|
||||
interface UseMobileProjectWorkspaceOptions {
|
||||
readonly selectedWorkUnitId?: string;
|
||||
}
|
||||
|
||||
export const useMobileProjectWorkspace = ({
|
||||
selectedWorkUnitId,
|
||||
}: UseMobileProjectWorkspaceOptions) => {
|
||||
const organization = usePersonalOrganization();
|
||||
const projectWorkspace = useProjectWorkspace();
|
||||
const chatAgent = useOrganizationChatAgent(organization);
|
||||
const signals = useQuery(
|
||||
api.signals.list,
|
||||
organization.organizationId
|
||||
? { organizationId: organization.organizationId }
|
||||
: "skip"
|
||||
);
|
||||
const assistant: MobileAssistantView = {
|
||||
isBusy:
|
||||
chatAgent.status === "connecting" ||
|
||||
chatAgent.status === "streaming" ||
|
||||
chatAgent.status === "submitted",
|
||||
messages: toAssistantMessages(chatAgent.messages),
|
||||
statusLabel: STATUS_COPY[chatAgent.status],
|
||||
statusTone: getStatusTone(chatAgent.status),
|
||||
};
|
||||
|
||||
return {
|
||||
data: buildMobileWorkspaceView({
|
||||
artifacts: projectWorkspace.artifacts,
|
||||
assistant,
|
||||
issues: projectWorkspace.issues,
|
||||
organizationLabel: organization.organizationName,
|
||||
projects: projectWorkspace.projects,
|
||||
selectedProject: projectWorkspace.selectedProject,
|
||||
selectedWorkUnitId,
|
||||
signals,
|
||||
}),
|
||||
error: organization.error ?? chatAgent.error,
|
||||
sendMessage: chatAgent.sendMessage,
|
||||
} as const;
|
||||
};
|
||||
52
apps/web/src/hooks/use-mobile-workspace.ts
Normal file
52
apps/web/src/hooks/use-mobile-workspace.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface UseMobileWorkspaceOptions {
|
||||
readonly initialExpanded?: boolean;
|
||||
readonly onSend: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : "Message could not be sent";
|
||||
|
||||
export const useMobileWorkspace = ({
|
||||
initialExpanded = false,
|
||||
onSend,
|
||||
}: UseMobileWorkspaceOptions) => {
|
||||
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;
|
||||
}
|
||||
setStatusMessage("Sending to Zopu…");
|
||||
const sendMessage = async () => {
|
||||
try {
|
||||
await onSend(message);
|
||||
setComposerValue("");
|
||||
setStatusMessage("Sent to Zopu");
|
||||
} catch (error) {
|
||||
setStatusMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
void sendMessage();
|
||||
};
|
||||
|
||||
return {
|
||||
composerValue,
|
||||
expanded,
|
||||
handleComposerChange,
|
||||
handleComposerSubmit,
|
||||
setExpanded,
|
||||
statusMessage,
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||
export interface PersonalOrganizationState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
readonly organizationName?: string;
|
||||
}
|
||||
|
||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||
@@ -16,10 +17,12 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
);
|
||||
const [organizationId, setOrganizationId] = useState<
|
||||
Id<"organizations"> | undefined
|
||||
>();
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
const [state, setState] = useState<{
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
readonly organizationName?: string;
|
||||
readonly userId: string;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
@@ -31,16 +34,21 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
try {
|
||||
const organization = await ensurePersonalOrganization();
|
||||
if (active) {
|
||||
setOrganizationId(organization._id);
|
||||
setError(undefined);
|
||||
setState({
|
||||
organizationId: organization._id,
|
||||
organizationName: organization.name,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
} catch (caughtError: unknown) {
|
||||
if (active) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError))
|
||||
);
|
||||
setState({
|
||||
error:
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError)),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -51,9 +59,13 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
};
|
||||
}, [ensurePersonalOrganization, userId]);
|
||||
|
||||
if (!userId) {
|
||||
if (!userId || state?.userId !== userId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { error, organizationId };
|
||||
return {
|
||||
error: state.error,
|
||||
organizationId: state.organizationId,
|
||||
organizationName: state.organizationName,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,49 +4,10 @@
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
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%);
|
||||
.ai-conversation-mobile > div {
|
||||
scrollbar-gutter: auto !important;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
@@ -120,15 +81,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;
|
||||
|
||||
164
apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts
Normal file
164
apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Doc } from "@code/backend/convex/_generated/dataModel";
|
||||
import type {
|
||||
MobileAssistantView,
|
||||
MobileSignalView,
|
||||
MobileWorkspaceView,
|
||||
MobileWorkUnitTone,
|
||||
MobileWorkUnitView,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
|
||||
type SignalList = FunctionReturnType<typeof api.signals.list>;
|
||||
type ProjectView = FunctionReturnType<typeof api.projects.list>[number];
|
||||
|
||||
interface BuildMobileWorkspaceViewInput {
|
||||
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
|
||||
readonly assistant: MobileAssistantView;
|
||||
readonly issues: readonly Doc<"projectIssues">[] | undefined;
|
||||
readonly organizationLabel?: string;
|
||||
readonly projects: readonly ProjectView[] | undefined;
|
||||
readonly selectedProject: ProjectView | null;
|
||||
readonly selectedWorkUnitId?: string;
|
||||
readonly signals: SignalList | undefined;
|
||||
}
|
||||
|
||||
const updatedDateFormatter = new Intl.DateTimeFormat("en", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
const statusPresentation: Record<
|
||||
Doc<"projectIssues">["status"],
|
||||
{
|
||||
readonly nextAction: string;
|
||||
readonly progress: number;
|
||||
readonly statusLabel: string;
|
||||
readonly tone: MobileWorkUnitTone;
|
||||
}
|
||||
> = {
|
||||
completed: {
|
||||
nextAction: "Review completed work",
|
||||
progress: 100,
|
||||
statusLabel: "Ready for review",
|
||||
tone: "green",
|
||||
},
|
||||
failed: {
|
||||
nextAction: "Retry this work unit",
|
||||
progress: 45,
|
||||
statusLabel: "Blocked",
|
||||
tone: "orange",
|
||||
},
|
||||
"needs-input": {
|
||||
nextAction: "Add the missing context",
|
||||
progress: 55,
|
||||
statusLabel: "Needs you",
|
||||
tone: "orange",
|
||||
},
|
||||
open: {
|
||||
nextAction: "Start this work unit",
|
||||
progress: 25,
|
||||
statusLabel: "Researching",
|
||||
tone: "purple",
|
||||
},
|
||||
queued: {
|
||||
nextAction: "Zopu is preparing",
|
||||
progress: 40,
|
||||
statusLabel: "Queued",
|
||||
tone: "blue",
|
||||
},
|
||||
working: {
|
||||
nextAction: "Review current progress",
|
||||
progress: 68,
|
||||
statusLabel: "In progress",
|
||||
tone: "blue",
|
||||
},
|
||||
};
|
||||
|
||||
const toWorkUnit = (
|
||||
issue: Doc<"projectIssues">,
|
||||
artifactCount: number
|
||||
): MobileWorkUnitView => {
|
||||
const presentation = statusPresentation[issue.status];
|
||||
return {
|
||||
artifactCount,
|
||||
code: `#${issue.number}`,
|
||||
id: issue._id,
|
||||
nextAction: presentation.nextAction,
|
||||
progress: presentation.progress,
|
||||
statusLabel: presentation.statusLabel,
|
||||
summary: issue.body,
|
||||
title: issue.title,
|
||||
tone: presentation.tone,
|
||||
updatedLabel: updatedDateFormatter.format(issue.updatedAt),
|
||||
};
|
||||
};
|
||||
|
||||
const toLatestSignalView = (
|
||||
signal: SignalList[number]["signal"] | undefined
|
||||
): MobileSignalView | undefined => {
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
desiredOutcome: signal.problemStatement.desiredOutcome,
|
||||
id: signal._id,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildMobileWorkspaceView = ({
|
||||
artifacts,
|
||||
assistant,
|
||||
issues,
|
||||
organizationLabel = "Personal workspace",
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedWorkUnitId,
|
||||
signals,
|
||||
}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => {
|
||||
const artifactCount = artifacts?.length ?? 0;
|
||||
const workUnits =
|
||||
issues?.map((issue) => toWorkUnit(issue, artifactCount)) ?? [];
|
||||
const selectedWorkUnit =
|
||||
workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ??
|
||||
workUnits.find((workUnit) => workUnit.tone === "blue") ??
|
||||
workUnits[0];
|
||||
const selectedProjectId = selectedProject?.id;
|
||||
const relevantSignals =
|
||||
signals?.filter(
|
||||
({ signal }) =>
|
||||
!signal.projectId ||
|
||||
String(signal.projectId) === String(selectedProjectId)
|
||||
) ?? [];
|
||||
const latestSignal = relevantSignals[0]?.signal;
|
||||
const activeCount = workUnits.filter(
|
||||
(workUnit) => workUnit.progress < 100
|
||||
).length;
|
||||
const needsAttentionCount = issues?.filter(
|
||||
(issue) => issue.status === "failed" || issue.status === "needs-input"
|
||||
).length;
|
||||
const shippedCount = issues?.filter(
|
||||
(issue) => issue.status === "completed"
|
||||
).length;
|
||||
const hasSelectedProject = Boolean(selectedProject);
|
||||
|
||||
return {
|
||||
activeCount,
|
||||
activityCount: workUnits.length + relevantSignals.length,
|
||||
artifactCount,
|
||||
assistant,
|
||||
isLoading:
|
||||
projects === undefined ||
|
||||
(hasSelectedProject && (artifacts === undefined || issues === undefined)),
|
||||
latestSignal: toLatestSignalView(latestSignal),
|
||||
needsAttentionCount: needsAttentionCount ?? 0,
|
||||
organizationLabel,
|
||||
projectName: selectedProject?.name,
|
||||
selectedWorkUnit,
|
||||
shippedCount: shippedCount ?? 0,
|
||||
totalCount: workUnits.length,
|
||||
workUnits,
|
||||
};
|
||||
};
|
||||
@@ -68,7 +68,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<body className="bg-[#11110f]">
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
@@ -81,13 +81,12 @@ const App = () => (
|
||||
<AuthenticatedFlueProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
defaultTheme="light"
|
||||
forcedTheme="light"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<div className="h-svh min-h-0 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<Outlet />
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</AuthenticatedFlueProvider>
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { index, layout, route } from "@react-router/dev/routes";
|
||||
import type { RouteConfig } from "@react-router/dev/routes";
|
||||
import { flatRoutes } from "@react-router/fs-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"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
12
apps/web/src/routes/app/mobile/chat/page.tsx
Normal file
12
apps/web/src/routes/app/mobile/chat/page.tsx
Normal 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" />;
|
||||
}
|
||||
9
apps/web/src/routes/app/mobile/layout.tsx
Normal file
9
apps/web/src/routes/app/mobile/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function MobileProductLayout() {
|
||||
return (
|
||||
<div className="min-h-svh bg-[#11110f]">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/routes/app/mobile/page.tsx
Normal file
15
apps/web/src/routes/app/mobile/page.tsx
Normal 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" />;
|
||||
}
|
||||
15
apps/web/src/routes/app/mobile/work-list/page.tsx
Normal file
15
apps/web/src/routes/app/mobile/work-list/page.tsx
Normal 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" />;
|
||||
}
|
||||
15
apps/web/src/routes/app/mobile/work/page.tsx
Normal file
15
apps/web/src/routes/app/mobile/work/page.tsx
Normal 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" />;
|
||||
}
|
||||
123
apps/web/src/routes/app/todos/page.tsx
Normal file
123
apps/web/src/routes/app/todos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
@@ -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" },
|
||||
@@ -3,12 +3,12 @@ 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: {
|
||||
dedupe: ["convex", "react", "react-dom"],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user