merge mobile workspace UI into integration
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@code/ui/components",
|
||||||
"utils": "@code/ui/lib/utils",
|
"utils": "@code/ui/lib/utils",
|
||||||
"ui": "@code/ui/components",
|
"ui": "@code/ui/components",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "react-router dev",
|
"dev": "react-router dev",
|
||||||
"dev:tailscale": "react-router dev --host 0.0.0.0",
|
"dev:tailscale": "react-router dev --host 0.0.0.0",
|
||||||
"start": "react-router-serve ./build/server/index.js",
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
"typecheck": "react-router typegen && tsc"
|
"check-types": "react-router typegen && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@code/auth": "workspace:*",
|
"@code/auth": "workspace:*",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { MessageHeader } from "@code/ui/components/message";
|
import { MobileChatAssistantLabel } from "@code/ui/components/mobile-chat";
|
||||||
import { Sparkles } from "lucide-react";
|
|
||||||
|
|
||||||
import type { AssistantResponseState } from "@/lib/chat/types";
|
import type { AssistantResponseState } from "@/lib/chat/types";
|
||||||
|
|
||||||
@@ -8,20 +7,18 @@ interface AssistantIdentityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AssistantIdentity = ({ state }: AssistantIdentityProps) => (
|
export const AssistantIdentity = ({ state }: AssistantIdentityProps) => (
|
||||||
<MessageHeader className="mb-1.5 gap-2.5 px-0 text-xs font-medium text-foreground/70">
|
<MobileChatAssistantLabel
|
||||||
<span className="zopu-mark flex size-6 items-center justify-center rounded-lg">
|
state={
|
||||||
<Sparkles className="size-3.5" />
|
state ? (
|
||||||
</span>
|
<span className="response-state" data-state={state}>
|
||||||
Zopu
|
<span aria-hidden="true" className="response-state-dots">
|
||||||
{state && (
|
<i />
|
||||||
<span className="response-state" data-state={state}>
|
<i />
|
||||||
<span aria-hidden="true" className="response-state-dots">
|
<i />
|
||||||
<i />
|
</span>
|
||||||
<i />
|
{state === "thinking" ? "Thinking" : "Writing"}
|
||||||
<i />
|
|
||||||
</span>
|
</span>
|
||||||
{state === "thinking" ? "Thinking" : "Writing"}
|
) : undefined
|
||||||
</span>
|
}
|
||||||
)}
|
/>
|
||||||
</MessageHeader>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import {
|
import { MobileChatComposer } from "@code/ui/components/mobile-chat";
|
||||||
InputGroup,
|
import { LoaderCircle } from "lucide-react";
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupButton,
|
|
||||||
InputGroupTextarea,
|
|
||||||
} from "@code/ui/components/input-group";
|
|
||||||
import { CircleAlert, LoaderCircle, Send } from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { useChatComposer } from "@/hooks/chat/use-chat-composer";
|
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";
|
status === "connecting" || status === "submitted" || status === "streaming";
|
||||||
const composer = useChatComposer({ busy, onSend });
|
const composer = useChatComposer({ busy, onSend });
|
||||||
|
|
||||||
let statusContent: ReactNode = (
|
let statusMessage: ReactNode;
|
||||||
<span className="hidden sm:inline">
|
|
||||||
Enter to send · Shift + Enter for a new line
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
if (busy) {
|
if (busy) {
|
||||||
statusContent = (
|
statusMessage = (
|
||||||
<>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<LoaderCircle className="size-3.5 animate-spin" />
|
<LoaderCircle className="size-3 animate-spin" />
|
||||||
{status === "connecting" ? "Preparing Zopu" : "Zopu is responding"}
|
{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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="composer-dock shrink-0 pb-[env(safe-area-inset-bottom)]">
|
<MobileChatComposer
|
||||||
<form
|
busy={busy}
|
||||||
aria-label="Send a message"
|
canSend={composer.canSend}
|
||||||
className="mx-auto w-full max-w-3xl px-3 pb-3 sm:px-5 sm:pb-5"
|
errorMessage={
|
||||||
onSubmit={composer.handleSubmit}
|
error ? error.message || "Message failed to send" : undefined
|
||||||
>
|
}
|
||||||
<InputGroup
|
onSubmit={composer.handleSubmit}
|
||||||
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 ${
|
statusMessage={statusMessage}
|
||||||
composer.isFocused
|
textareaProps={{
|
||||||
? "composer-shell-expanded"
|
...composer.inputProps,
|
||||||
: "composer-shell-compact"
|
"aria-describedby": error ? "composer-error" : undefined,
|
||||||
}`}
|
"aria-invalid": error ? true : undefined,
|
||||||
data-disabled={busy || undefined}
|
disabled: busy,
|
||||||
>
|
}}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
|
import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation";
|
||||||
import { Button } from "@code/ui/components/button";
|
import { Button } from "@code/ui/components/button";
|
||||||
import {
|
import { MobileChatMark } from "@code/ui/components/mobile-chat";
|
||||||
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 { SUGGESTIONS } from "@/lib/chat/constants";
|
import { SUGGESTIONS } from "@/lib/chat/constants";
|
||||||
import type { ChatConversationProps } from "@/lib/chat/types";
|
import type { ChatConversationProps } from "@/lib/chat/types";
|
||||||
@@ -25,23 +16,21 @@ export const ChatConversation = ({
|
|||||||
}: ChatConversationProps) => {
|
}: ChatConversationProps) => {
|
||||||
if (historyReady && messages.length === 0) {
|
if (historyReady && messages.length === 0) {
|
||||||
return (
|
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">
|
<ConversationEmptyState className="min-h-full items-start justify-end px-0 pt-14 pb-5 text-left">
|
||||||
<EmptyMedia className="zopu-mark size-11 rounded-2xl" variant="icon">
|
<MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
|
||||||
<Sparkles className="size-5" />
|
<div className="space-y-1">
|
||||||
</EmptyMedia>
|
<h2 className="text-[22px] font-semibold tracking-[-0.035em]">
|
||||||
<EmptyHeader className="items-start text-left sm:items-center sm:text-center">
|
|
||||||
<EmptyTitle className="text-2xl font-semibold tracking-[-0.035em] sm:text-3xl">
|
|
||||||
What can I help with?
|
What can I help with?
|
||||||
</EmptyTitle>
|
</h2>
|
||||||
<EmptyDescription className="max-w-md text-[15px] leading-6">
|
<p className="max-w-[320px] text-[14px] leading-5 text-muted-foreground">
|
||||||
Think, write, plan, or explore an idea with Zopu.
|
Think, write, plan, or explore an idea with Zopu.
|
||||||
</EmptyDescription>
|
</p>
|
||||||
</EmptyHeader>
|
</div>
|
||||||
<EmptyContent className="mt-3 grid w-full max-w-xl gap-2 sm:grid-cols-3">
|
<div className="mt-3 grid w-full gap-2">
|
||||||
{SUGGESTIONS.map(([title, prompt]) => (
|
{SUGGESTIONS.map(([title, prompt]) => (
|
||||||
<Button
|
<Button
|
||||||
key={title}
|
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)}
|
onClick={() => onSuggestion(prompt)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@@ -51,27 +40,17 @@ export const ChatConversation = ({
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</EmptyContent>
|
</div>
|
||||||
</Empty>
|
</ConversationEmptyState>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageGroup className="gap-8 pb-5 md:gap-10 md:pb-8">
|
<div className="flex min-w-0 flex-col gap-5 pb-5">
|
||||||
{messages.map((message, index) => (
|
{messages.map((message) => (
|
||||||
<MessageScrollerItem
|
<ChatMessage key={message.id} message={message} />
|
||||||
key={message.id}
|
|
||||||
messageId={message.id}
|
|
||||||
scrollAnchor={index === messages.length - 1}
|
|
||||||
>
|
|
||||||
<ChatMessage message={message} />
|
|
||||||
</MessageScrollerItem>
|
|
||||||
))}
|
))}
|
||||||
{status === "submitted" && (
|
{status === "submitted" && <ChatThinkingResponse />}
|
||||||
<MessageScrollerItem scrollAnchor>
|
</div>
|
||||||
<ChatThinkingResponse />
|
|
||||||
</MessageScrollerItem>
|
|
||||||
)}
|
|
||||||
</MessageGroup>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { STATUS_COPY } from "@/lib/chat/constants";
|
||||||
import { getStatusDotClass } from "@/lib/chat/transforms";
|
|
||||||
import type { ChatHeaderProps } from "@/lib/chat/types";
|
import type { ChatHeaderProps } from "@/lib/chat/types";
|
||||||
|
|
||||||
export const ChatHeader = ({ status }: ChatHeaderProps) => (
|
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">
|
<MobileChatHeader
|
||||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between">
|
active={status !== "connecting" && status !== "error"}
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
statusLabel={STATUS_COPY[status]}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Bubble, BubbleContent } from "@code/ui/components/bubble";
|
import {
|
||||||
import { Message, MessageContent } from "@code/ui/components/message";
|
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 type { ReactNode } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
|
||||||
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
|
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
|
||||||
import type { ChatMessageProps } from "@/lib/chat/types";
|
import type { ChatMessageProps } from "@/lib/chat/types";
|
||||||
@@ -21,13 +27,13 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
|
|||||||
let content: ReactNode = text;
|
let content: ReactNode = text;
|
||||||
if (!isUser) {
|
if (!isUser) {
|
||||||
content = text ? (
|
content = text ? (
|
||||||
<Streamdown
|
<MessageResponse
|
||||||
caret={isStreaming ? "block" : undefined}
|
caret={isStreaming ? "block" : undefined}
|
||||||
className="chat-markdown min-w-0"
|
className="chat-markdown min-w-0"
|
||||||
isAnimating={isStreaming}
|
isAnimating={isStreaming}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Streamdown>
|
</MessageResponse>
|
||||||
) : (
|
) : (
|
||||||
<div className="thinking-line" aria-label="Zopu is preparing a response">
|
<div className="thinking-line" aria-label="Zopu is preparing a response">
|
||||||
<span />
|
<span />
|
||||||
@@ -37,41 +43,29 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sender = isUser ? "user" : "assistant";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Message
|
<Message className="max-w-full gap-0" from={message.role}>
|
||||||
align={isUser ? "end" : "start"}
|
<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">
|
||||||
className="chat-message text-[15px] md:text-base"
|
<MobileChatMessage className="chat-message" sender={sender}>
|
||||||
>
|
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
|
||||||
<MessageContent
|
{!isUser && (
|
||||||
className={
|
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
|
||||||
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
|
|
||||||
)}
|
)}
|
||||||
{content}
|
<MobileChatBubble
|
||||||
</BubbleContent>
|
className={isUser ? "whitespace-pre-wrap" : undefined}
|
||||||
</Bubble>
|
sender={sender}
|
||||||
|
>
|
||||||
|
{message.parts.map((part) =>
|
||||||
|
part.type === "dynamic-tool" ? (
|
||||||
|
<ChatToolCall key={part.toolCallId} part={part} />
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
{content}
|
||||||
|
</MobileChatBubble>
|
||||||
|
</div>
|
||||||
|
</MobileChatMessage>
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</Message>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
MessageScroller,
|
Conversation,
|
||||||
MessageScrollerButton,
|
ConversationContent,
|
||||||
MessageScrollerContent,
|
ConversationScrollButton,
|
||||||
MessageScrollerProvider,
|
} from "@code/ui/components/ai-elements/conversation";
|
||||||
MessageScrollerViewport,
|
|
||||||
} from "@code/ui/components/message-scroller";
|
|
||||||
|
|
||||||
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
|
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||||
|
|
||||||
@@ -17,36 +15,24 @@ export const ChatPage = () => {
|
|||||||
const handleSendMessage = (message: string) => agent.sendMessage(message);
|
const handleSendMessage = (message: string) => agent.sendMessage(message);
|
||||||
|
|
||||||
return (
|
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} />
|
<ChatHeader status={agent.status} />
|
||||||
|
|
||||||
<main aria-label="Conversation" className="min-h-0 flex-1">
|
<main aria-label="Conversation" className="min-h-0 flex-1">
|
||||||
<MessageScrollerProvider
|
<Conversation className="ai-conversation-mobile h-full">
|
||||||
autoScroll
|
<ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
|
||||||
defaultScrollPosition="end"
|
<ChatConversation
|
||||||
scrollEdgeThreshold={96}
|
historyReady={agent.historyReady}
|
||||||
scrollMargin={24}
|
messages={agent.messages}
|
||||||
>
|
onSuggestion={(suggestion) => void handleSendMessage(suggestion)}
|
||||||
<MessageScroller>
|
status={agent.status}
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
</MessageScroller>
|
</ConversationContent>
|
||||||
</MessageScrollerProvider>
|
<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>
|
</main>
|
||||||
|
|
||||||
<ChatComposer
|
<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";
|
import { AssistantIdentity } from "./assistant-identity";
|
||||||
|
|
||||||
export const ChatThinkingResponse = () => (
|
export const ChatThinkingResponse = () => (
|
||||||
<Message align="start" className="chat-message">
|
<Message className="max-w-full gap-0" from="assistant">
|
||||||
<MessageContent className="max-w-full md:max-w-[92%]">
|
<MessageContent className="w-full max-w-none gap-0 overflow-visible p-0">
|
||||||
<AssistantIdentity state="thinking" />
|
<MobileChatMessage className="chat-message" sender="assistant">
|
||||||
<div className="thinking-line" aria-label="Zopu is thinking">
|
<div className="w-full min-w-0">
|
||||||
<span />
|
<AssistantIdentity state="thinking" />
|
||||||
<span />
|
<MobileChatBubble sender="assistant">
|
||||||
<span />
|
<div className="thinking-line" aria-label="Zopu is thinking">
|
||||||
</div>
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</MobileChatBubble>
|
||||||
|
</div>
|
||||||
|
</MobileChatMessage>
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</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";
|
import type { ChatToolCallProps } from "@/lib/chat/types";
|
||||||
|
|
||||||
@@ -7,35 +14,47 @@ export const ChatToolCall = ({ part }: ChatToolCallProps) => {
|
|||||||
typeof part.input === "string"
|
typeof part.input === "string"
|
||||||
? part.input
|
? part.input
|
||||||
: JSON.stringify(part.input, null, 2);
|
: 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") {
|
if (part.state === "output-available") {
|
||||||
detail =
|
detail =
|
||||||
typeof part.output === "string"
|
typeof part.output === "string"
|
||||||
? part.output
|
? part.output
|
||||||
: JSON.stringify(part.output, null, 2);
|
: JSON.stringify(part.output, null, 2);
|
||||||
Icon = Check;
|
status = "done";
|
||||||
status = "Completed";
|
tone = "success";
|
||||||
} else if (part.state === "output-error") {
|
} else if (part.state === "output-error") {
|
||||||
detail = part.errorText;
|
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 (
|
return (
|
||||||
<details className="group/tool overflow-hidden rounded-xl border border-border/60 bg-card/45 text-xs">
|
<Tool className="mb-0 w-full border-0" defaultOpen>
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-muted-foreground marker:hidden">
|
<ToolContent className="space-y-0 p-0">
|
||||||
<Terminal className="size-3.5 shrink-0" />
|
<MobileChatToolCall
|
||||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85">
|
detail={detail}
|
||||||
{part.toolName}
|
icon={
|
||||||
</span>
|
isSearch ? (
|
||||||
<span>{status}</span>
|
<Search className="size-3" strokeWidth={2.2} />
|
||||||
<Icon className={`size-3.5 ${isRunning ? "animate-spin" : ""}`} />
|
) : (
|
||||||
</summary>
|
<SquareTerminal className="size-3" strokeWidth={2.2} />
|
||||||
<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>
|
status={status}
|
||||||
</details>
|
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 { useFlueAgent } from "@flue/react";
|
||||||
|
|
||||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||||
|
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
||||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
import { CHAT_AGENT } from "@/lib/chat/constants";
|
||||||
import type { ChatAgentState } from "@/lib/chat/types";
|
import type { ChatAgentState } from "@/lib/chat/types";
|
||||||
|
|
||||||
export const useChatAgent = (): ChatAgentState => {
|
export const useOrganizationChatAgent = (
|
||||||
// The agent session is not created until the authenticated user's personal
|
organization: PersonalOrganizationState
|
||||||
// organization has been ensured and its id is available.
|
): ChatAgentState => {
|
||||||
const organization = usePersonalOrganization();
|
|
||||||
const agent = useFlueAgent({
|
const agent = useFlueAgent({
|
||||||
...CHAT_AGENT,
|
...CHAT_AGENT,
|
||||||
id: organization.organizationId,
|
id: organization.organizationId,
|
||||||
@@ -36,3 +36,6 @@ export const useChatAgent = (): ChatAgentState => {
|
|||||||
status,
|
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 {
|
export interface PersonalOrganizationState {
|
||||||
readonly error?: Error;
|
readonly error?: Error;
|
||||||
readonly organizationId?: Id<"organizations">;
|
readonly organizationId?: Id<"organizations">;
|
||||||
|
readonly organizationName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||||
@@ -16,10 +17,12 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
|||||||
const ensurePersonalOrganization = useMutation(
|
const ensurePersonalOrganization = useMutation(
|
||||||
api.organizations.ensurePersonalOrganization
|
api.organizations.ensurePersonalOrganization
|
||||||
);
|
);
|
||||||
const [organizationId, setOrganizationId] = useState<
|
const [state, setState] = useState<{
|
||||||
Id<"organizations"> | undefined
|
readonly error?: Error;
|
||||||
>();
|
readonly organizationId?: Id<"organizations">;
|
||||||
const [error, setError] = useState<Error | undefined>();
|
readonly organizationName?: string;
|
||||||
|
readonly userId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -31,16 +34,21 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
|||||||
try {
|
try {
|
||||||
const organization = await ensurePersonalOrganization();
|
const organization = await ensurePersonalOrganization();
|
||||||
if (active) {
|
if (active) {
|
||||||
setOrganizationId(organization._id);
|
setState({
|
||||||
setError(undefined);
|
organizationId: organization._id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (caughtError: unknown) {
|
} catch (caughtError: unknown) {
|
||||||
if (active) {
|
if (active) {
|
||||||
setError(
|
setState({
|
||||||
caughtError instanceof Error
|
error:
|
||||||
? caughtError
|
caughtError instanceof Error
|
||||||
: new Error(String(caughtError))
|
? caughtError
|
||||||
);
|
: new Error(String(caughtError)),
|
||||||
|
userId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -51,9 +59,13 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
|||||||
};
|
};
|
||||||
}, [ensurePersonalOrganization, userId]);
|
}, [ensurePersonalOrganization, userId]);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId || state?.userId !== userId) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error, organizationId };
|
return {
|
||||||
|
error: state.error,
|
||||||
|
organizationId: state.organizationId,
|
||||||
|
organizationName: state.organizationName,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,49 +4,10 @@
|
|||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-surface {
|
.ai-conversation-mobile > div {
|
||||||
position: relative;
|
scrollbar-gutter: auto !important;
|
||||||
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 {
|
.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 {
|
@keyframes chat-message-enter {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
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 />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body className="bg-[#11110f]">
|
||||||
{children}
|
{children}
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
@@ -81,13 +81,12 @@ const App = () => (
|
|||||||
<AuthenticatedFlueProvider>
|
<AuthenticatedFlueProvider>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="dark"
|
defaultTheme="light"
|
||||||
|
forcedTheme="light"
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
storageKey="vite-ui-theme"
|
storageKey="vite-ui-theme"
|
||||||
>
|
>
|
||||||
<div className="h-svh min-h-0 overflow-hidden">
|
<Outlet />
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AuthenticatedFlueProvider>
|
</AuthenticatedFlueProvider>
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
|
import { index, layout, route } from "@react-router/dev/routes";
|
||||||
import type { RouteConfig } 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 { LoginForm } from "@code/auth/web";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/_auth.login";
|
import type { Route } from "./+types/page";
|
||||||
|
|
||||||
export const meta = (_args: Route.MetaArgs) => [
|
export const meta = (_args: Route.MetaArgs) => [
|
||||||
{ title: "Sign in | Zopu" },
|
{ title: "Sign in | Zopu" },
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SignupForm } from "@code/auth/web";
|
import { SignupForm } from "@code/auth/web";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/_auth.signup";
|
import type { Route } from "./+types/page";
|
||||||
|
|
||||||
export const meta = (_args: Route.MetaArgs) => [
|
export const meta = (_args: Route.MetaArgs) => [
|
||||||
{ title: "Create account | Zopu" },
|
{ title: "Create account | Zopu" },
|
||||||
@@ -3,12 +3,12 @@ import path from "node:path";
|
|||||||
import { reactRouter } from "@react-router/dev/vite";
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite-plus";
|
import { defineConfig } from "vite-plus";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
envDir: path.resolve(import.meta.dirname, "../.."),
|
envDir: path.resolve(import.meta.dirname, "../.."),
|
||||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
plugins: [tailwindcss(), reactRouter()],
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ["convex", "react", "react-dom"],
|
dedupe: ["convex", "react", "react-dom"],
|
||||||
|
tsconfigPaths: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import type { Id } from "./_generated/dataModel";
|
|||||||
import { action, internalMutation, query } from "./_generated/server";
|
import { action, internalMutation, query } from "./_generated/server";
|
||||||
import { createInitialArtifacts } from "./artifactModel";
|
import { createInitialArtifacts } from "./artifactModel";
|
||||||
import { requireCurrentOrganization } from "./authz";
|
import { requireCurrentOrganization } from "./authz";
|
||||||
import { persistPublicGitImportTransaction } from "./projectStore";
|
import {
|
||||||
|
makeProjectMutationStore,
|
||||||
|
makeProjectQueryStore,
|
||||||
|
persistPublicGitImportTransaction,
|
||||||
|
} from "./projectStore";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal: persist a public Git import in one transaction
|
// Internal: persist a public Git import in one transaction
|
||||||
@@ -146,7 +150,6 @@ export const putContextDocument = internalMutation({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const { makeProjectMutationStore } = await import("./projectStore");
|
|
||||||
const store = makeProjectMutationStore(ctx);
|
const store = makeProjectMutationStore(ctx);
|
||||||
return store.putContext(args.userId, args.projectId, args.write as never);
|
return store.putContext(args.userId, args.projectId, args.write as never);
|
||||||
},
|
},
|
||||||
@@ -160,7 +163,6 @@ export const list = query({
|
|||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx): Promise<ProjectView[]> => {
|
handler: async (ctx): Promise<ProjectView[]> => {
|
||||||
const { userId } = await requireCurrentOrganization(ctx);
|
const { userId } = await requireCurrentOrganization(ctx);
|
||||||
const { makeProjectQueryStore } = await import("./projectStore");
|
|
||||||
const store = makeProjectQueryStore(ctx);
|
const store = makeProjectQueryStore(ctx);
|
||||||
return store.listProjects(userId);
|
return store.listProjects(userId);
|
||||||
},
|
},
|
||||||
@@ -174,7 +176,6 @@ export const get = query({
|
|||||||
args: { projectId: v.id("projects") },
|
args: { projectId: v.id("projects") },
|
||||||
handler: async (ctx, args): Promise<ProjectView | null> => {
|
handler: async (ctx, args): Promise<ProjectView | null> => {
|
||||||
const { userId } = await requireCurrentOrganization(ctx);
|
const { userId } = await requireCurrentOrganization(ctx);
|
||||||
const { makeProjectQueryStore } = await import("./projectStore");
|
|
||||||
const store = makeProjectQueryStore(ctx);
|
const store = makeProjectQueryStore(ctx);
|
||||||
return store.getProject(userId, args.projectId);
|
return store.getProject(userId, args.projectId);
|
||||||
},
|
},
|
||||||
@@ -216,7 +217,6 @@ export const putText = internalMutation({
|
|||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const { userId } = await requireCurrentOrganization(ctx);
|
const { userId } = await requireCurrentOrganization(ctx);
|
||||||
const { makeProjectMutationStore } = await import("./projectStore");
|
|
||||||
const store = makeProjectMutationStore(ctx);
|
const store = makeProjectMutationStore(ctx);
|
||||||
return store.putContext(userId, args.projectId, {
|
return store.putContext(userId, args.projectId, {
|
||||||
_tag: "UserText" as const,
|
_tag: "UserText" as const,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
"@shadcn/react": "^0.2.0",
|
"@shadcn/react": "^0.2.0",
|
||||||
|
"@streamdown/cjk": "^1.0.3",
|
||||||
|
"@streamdown/code": "^1.1.1",
|
||||||
|
"@streamdown/math": "^1.0.2",
|
||||||
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
|
"ai": "^7.0.35",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
@@ -23,9 +28,12 @@
|
|||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"shadcn": "^4.12.0",
|
"shadcn": "^4.12.0",
|
||||||
|
"shiki": "^4.3.1",
|
||||||
"sonner": "catalog:",
|
"sonner": "catalog:",
|
||||||
|
"streamdown": "^2.5.0",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"use-stick-to-bottom": "^1.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
|
|||||||
525
packages/ui/src/components/ai-elements/code-block.tsx
Normal file
525
packages/ui/src/components/ai-elements/code-block.tsx
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@code/ui/components/select";
|
||||||
|
import { cn } from "@code/ui/lib/utils";
|
||||||
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import type {
|
||||||
|
BundledLanguage,
|
||||||
|
BundledTheme,
|
||||||
|
HighlighterGeneric,
|
||||||
|
ThemedToken,
|
||||||
|
} from "shiki";
|
||||||
|
import { createHighlighter } from "shiki";
|
||||||
|
|
||||||
|
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline.
|
||||||
|
const hasFontStyle = (fontStyle: number | undefined, flag: number) =>
|
||||||
|
Math.floor((fontStyle ?? 0) / flag) % 2 === 1;
|
||||||
|
const isItalic = (fontStyle: number | undefined) => hasFontStyle(fontStyle, 1);
|
||||||
|
const isBold = (fontStyle: number | undefined) => hasFontStyle(fontStyle, 2);
|
||||||
|
const isUnderline = (fontStyle: number | undefined) =>
|
||||||
|
hasFontStyle(fontStyle, 4);
|
||||||
|
|
||||||
|
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||||
|
interface KeyedToken {
|
||||||
|
token: ThemedToken;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
interface KeyedLine {
|
||||||
|
tokens: KeyedToken[];
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
||||||
|
lines.map((line, lineIdx) => ({
|
||||||
|
key: `line-${lineIdx}`,
|
||||||
|
tokens: line.map((token, tokenIdx) => ({
|
||||||
|
key: `line-${lineIdx}-${tokenIdx}`,
|
||||||
|
token,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Token rendering component
|
||||||
|
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
||||||
|
<span
|
||||||
|
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
backgroundColor: token.bgColor,
|
||||||
|
color: token.color,
|
||||||
|
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
||||||
|
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
||||||
|
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
||||||
|
...token.htmlStyle,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{token.content}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Line number styles using CSS counters
|
||||||
|
const LINE_NUMBER_CLASSES = cn(
|
||||||
|
"block",
|
||||||
|
"before:content-[counter(line)]",
|
||||||
|
"before:inline-block",
|
||||||
|
"before:[counter-increment:line]",
|
||||||
|
"before:w-8",
|
||||||
|
"before:mr-4",
|
||||||
|
"before:text-right",
|
||||||
|
"before:text-muted-foreground/50",
|
||||||
|
"before:font-mono",
|
||||||
|
"before:select-none"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Line rendering component
|
||||||
|
const LineSpan = ({
|
||||||
|
keyedLine,
|
||||||
|
showLineNumbers,
|
||||||
|
}: {
|
||||||
|
keyedLine: KeyedLine;
|
||||||
|
showLineNumbers: boolean;
|
||||||
|
}) => (
|
||||||
|
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
||||||
|
{keyedLine.tokens.length === 0
|
||||||
|
? "\n"
|
||||||
|
: keyedLine.tokens.map(({ token, key }) => (
|
||||||
|
<TokenSpan key={key} token={token} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
code: string;
|
||||||
|
language: BundledLanguage;
|
||||||
|
showLineNumbers?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TokenizedCode {
|
||||||
|
tokens: ThemedToken[][];
|
||||||
|
fg: string;
|
||||||
|
bg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeBlockContextType {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context
|
||||||
|
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||||
|
code: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlighter cache (singleton per language)
|
||||||
|
const highlighterCache = new Map<
|
||||||
|
string,
|
||||||
|
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Token cache
|
||||||
|
const tokensCache = new Map<string, TokenizedCode>();
|
||||||
|
|
||||||
|
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||||
|
const start = code.slice(0, 100);
|
||||||
|
const end = code.length > 100 ? code.slice(-100) : "";
|
||||||
|
return `${language}:${code.length}:${start}:${end}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHighlighter = (
|
||||||
|
language: BundledLanguage
|
||||||
|
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||||
|
const cached = highlighterCache.get(language);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlighterPromise = createHighlighter({
|
||||||
|
langs: [language],
|
||||||
|
themes: ["github-light", "github-dark"],
|
||||||
|
});
|
||||||
|
|
||||||
|
highlighterCache.set(language, highlighterPromise);
|
||||||
|
return highlighterPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create raw tokens for immediate display while highlighting loads
|
||||||
|
const createRawTokens = (code: string): TokenizedCode => ({
|
||||||
|
bg: "transparent",
|
||||||
|
fg: "inherit",
|
||||||
|
tokens: code.split("\n").map((line) =>
|
||||||
|
line === ""
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
color: "inherit",
|
||||||
|
content: line,
|
||||||
|
} as ThemedToken,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const highlightCode = async (
|
||||||
|
code: string,
|
||||||
|
language: BundledLanguage
|
||||||
|
): Promise<TokenizedCode> => {
|
||||||
|
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||||
|
|
||||||
|
const cached = tokensCache.get(tokensCacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlighter = await getHighlighter(language);
|
||||||
|
const availableLangs = highlighter.getLoadedLanguages();
|
||||||
|
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||||
|
const result = highlighter.codeToTokens(code, {
|
||||||
|
lang: langToUse,
|
||||||
|
themes: {
|
||||||
|
dark: "github-dark",
|
||||||
|
light: "github-light",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tokenized: TokenizedCode = {
|
||||||
|
bg: result.bg ?? "transparent",
|
||||||
|
fg: result.fg ?? "inherit",
|
||||||
|
tokens: result.tokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokensCache.set(tokensCacheKey, tokenized);
|
||||||
|
return tokenized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CodeBlockBody = memo(
|
||||||
|
({
|
||||||
|
tokenized,
|
||||||
|
showLineNumbers,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tokenized: TokenizedCode;
|
||||||
|
showLineNumbers: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const preStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
backgroundColor: tokenized.bg,
|
||||||
|
color: tokenized.fg,
|
||||||
|
}),
|
||||||
|
[tokenized.bg, tokenized.fg]
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyedLines = useMemo(
|
||||||
|
() => addKeysToTokens(tokenized.tokens),
|
||||||
|
[tokenized.tokens]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={preStyle}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-sm",
|
||||||
|
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{keyedLines.map((keyedLine) => (
|
||||||
|
<LineSpan
|
||||||
|
key={keyedLine.key}
|
||||||
|
keyedLine={keyedLine}
|
||||||
|
showLineNumbers={showLineNumbers}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.tokenized === nextProps.tokenized &&
|
||||||
|
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
||||||
|
prevProps.className === nextProps.className
|
||||||
|
);
|
||||||
|
|
||||||
|
CodeBlockBody.displayName = "CodeBlockBody";
|
||||||
|
|
||||||
|
export const CodeBlockContainer = ({
|
||||||
|
className,
|
||||||
|
language,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-language={language}
|
||||||
|
style={{
|
||||||
|
containIntrinsicSize: "auto 200px",
|
||||||
|
contentVisibility: "auto",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CodeBlockHeader = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CodeBlockTitle = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CodeBlockFilename = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLSpanElement>) => (
|
||||||
|
<span className={cn("font-mono", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CodeBlockActions = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CodeBlockContent = ({
|
||||||
|
code,
|
||||||
|
language,
|
||||||
|
showLineNumbers = false,
|
||||||
|
}: {
|
||||||
|
code: string;
|
||||||
|
language: BundledLanguage;
|
||||||
|
showLineNumbers?: boolean;
|
||||||
|
}) => {
|
||||||
|
// Memoized raw tokens for immediate display
|
||||||
|
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
||||||
|
|
||||||
|
const cacheKey = getTokensCacheKey(code, language);
|
||||||
|
|
||||||
|
// Synchronous cache lookup avoids a loading flash after the first highlight.
|
||||||
|
const syncTokens = useMemo(
|
||||||
|
() => tokensCache.get(cacheKey) ?? rawTokens,
|
||||||
|
[cacheKey, rawTokens]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [asyncResult, setAsyncResult] = useState<{
|
||||||
|
cacheKey: string;
|
||||||
|
tokens: TokenizedCode;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadHighlightedTokens = async () => {
|
||||||
|
try {
|
||||||
|
const result = await highlightCode(code, language);
|
||||||
|
if (!cancelled) {
|
||||||
|
setAsyncResult({ cacheKey, tokens: result });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to highlight code:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadHighlightedTokens();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [cacheKey, code, language]);
|
||||||
|
|
||||||
|
const tokenized =
|
||||||
|
asyncResult?.cacheKey === cacheKey ? asyncResult.tokens : syncTokens;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-auto">
|
||||||
|
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodeBlock = ({
|
||||||
|
code,
|
||||||
|
language,
|
||||||
|
showLineNumbers = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: CodeBlockProps) => {
|
||||||
|
const contextValue = useMemo(() => ({ code }), [code]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeBlockContext.Provider value={contextValue}>
|
||||||
|
<CodeBlockContainer className={className} language={language} {...props}>
|
||||||
|
{children}
|
||||||
|
<CodeBlockContent
|
||||||
|
code={code}
|
||||||
|
language={language}
|
||||||
|
showLineNumbers={showLineNumbers}
|
||||||
|
/>
|
||||||
|
</CodeBlockContainer>
|
||||||
|
</CodeBlockContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||||
|
onCopy?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodeBlockCopyButton = ({
|
||||||
|
onCopy,
|
||||||
|
onError,
|
||||||
|
timeout = 2000,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: CodeBlockCopyButtonProps) => {
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
const timeoutRef = useRef<number>(0);
|
||||||
|
const { code } = useContext(CodeBlockContext);
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback(async () => {
|
||||||
|
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||||
|
onError?.(new Error("Clipboard API not available"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isCopied) {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setIsCopied(true);
|
||||||
|
onCopy?.();
|
||||||
|
timeoutRef.current = window.setTimeout(
|
||||||
|
() => setIsCopied(false),
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error as Error);
|
||||||
|
}
|
||||||
|
}, [code, onCopy, onError, timeout, isCopied]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn("shrink-0", className)}
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <Icon size={14} />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
||||||
|
|
||||||
|
export const CodeBlockLanguageSelector = (
|
||||||
|
props: CodeBlockLanguageSelectorProps
|
||||||
|
) => <Select {...props} />;
|
||||||
|
|
||||||
|
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
||||||
|
typeof SelectTrigger
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const CodeBlockLanguageSelectorTrigger = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: CodeBlockLanguageSelectorTriggerProps) => (
|
||||||
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
||||||
|
typeof SelectValue
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const CodeBlockLanguageSelectorValue = (
|
||||||
|
props: CodeBlockLanguageSelectorValueProps
|
||||||
|
) => <SelectValue {...props} />;
|
||||||
|
|
||||||
|
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
||||||
|
typeof SelectContent
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const CodeBlockLanguageSelectorContent = ({
|
||||||
|
align = "end",
|
||||||
|
...props
|
||||||
|
}: CodeBlockLanguageSelectorContentProps) => (
|
||||||
|
<SelectContent align={align} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
||||||
|
typeof SelectItem
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const CodeBlockLanguageSelectorItem = (
|
||||||
|
props: CodeBlockLanguageSelectorItemProps
|
||||||
|
) => <SelectItem {...props} />;
|
||||||
168
packages/ui/src/components/ai-elements/conversation.tsx
Normal file
168
packages/ui/src/components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { cn } from "@code/ui/lib/utils";
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
|
|
||||||
|
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||||
|
|
||||||
|
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||||
|
<StickToBottom
|
||||||
|
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||||
|
initial="smooth"
|
||||||
|
resize="smooth"
|
||||||
|
role="log"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationContentProps = ComponentProps<
|
||||||
|
typeof StickToBottom.Content
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ConversationContent = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ConversationContentProps) => (
|
||||||
|
<StickToBottom.Content
|
||||||
|
className={cn("flex flex-col gap-8 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConversationEmptyState = ({
|
||||||
|
className,
|
||||||
|
title = "No messages yet",
|
||||||
|
description = "Start a conversation to see messages here",
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ConversationEmptyStateProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-sm">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const ConversationScrollButton = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ConversationScrollButtonProps) => {
|
||||||
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||||
|
|
||||||
|
const handleScrollToBottom = useCallback(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [scrollToBottom]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
!isAtBottom && (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleScrollToBottom}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageText = (message: UIMessage): string =>
|
||||||
|
message.parts
|
||||||
|
.filter((part) => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
export type ConversationDownloadProps = Omit<
|
||||||
|
ComponentProps<typeof Button>,
|
||||||
|
"onClick"
|
||||||
|
> & {
|
||||||
|
messages: UIMessage[];
|
||||||
|
filename?: string;
|
||||||
|
formatMessage?: (message: UIMessage, index: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultFormatMessage = (message: UIMessage): string => {
|
||||||
|
const roleLabel =
|
||||||
|
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
||||||
|
return `**${roleLabel}:** ${getMessageText(message)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const messagesToMarkdown = (
|
||||||
|
messages: UIMessage[],
|
||||||
|
formatMessage: (
|
||||||
|
message: UIMessage,
|
||||||
|
index: number
|
||||||
|
) => string = defaultFormatMessage
|
||||||
|
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
||||||
|
|
||||||
|
export const ConversationDownload = ({
|
||||||
|
messages,
|
||||||
|
filename = "conversation.md",
|
||||||
|
formatMessage = defaultFormatMessage,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ConversationDownloadProps) => {
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const markdown = messagesToMarkdown(messages, formatMessage);
|
||||||
|
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [messages, filename, formatMessage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <DownloadIcon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
357
packages/ui/src/components/ai-elements/message.tsx
Normal file
357
packages/ui/src/components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { ButtonGroup, ButtonGroupText } from "@code/ui/components/button-group";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@code/ui/components/tooltip";
|
||||||
|
import { cn } from "@code/ui/lib/utils";
|
||||||
|
import { cjk } from "@streamdown/cjk";
|
||||||
|
import { code } from "@streamdown/code";
|
||||||
|
import { math } from "@streamdown/math";
|
||||||
|
import { mermaid } from "@streamdown/mermaid";
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
from: UIMessage["role"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex w-full max-w-[95%] flex-col gap-2",
|
||||||
|
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const MessageContent = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageContentProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||||
|
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||||
|
"group-[.is-assistant]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageActionsProps = ComponentProps<"div">;
|
||||||
|
|
||||||
|
export const MessageActions = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageActionsProps) => (
|
||||||
|
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||||
|
tooltip?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageAction = ({
|
||||||
|
tooltip,
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
variant = "ghost",
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}: MessageActionProps) => {
|
||||||
|
const button = (
|
||||||
|
<Button size={size} type="button" variant={variant} {...props}>
|
||||||
|
{children}
|
||||||
|
<span className="sr-only">{label || tooltip}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MessageBranchContextType {
|
||||||
|
currentBranch: number;
|
||||||
|
totalBranches: number;
|
||||||
|
goToPrevious: () => void;
|
||||||
|
goToNext: () => void;
|
||||||
|
branches: ReactElement[];
|
||||||
|
setBranches: (branches: ReactElement[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const useMessageBranch = () => {
|
||||||
|
const context = useContext(MessageBranchContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"MessageBranch components must be used within MessageBranch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
defaultBranch?: number;
|
||||||
|
onBranchChange?: (branchIndex: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageBranch = ({
|
||||||
|
defaultBranch = 0,
|
||||||
|
onBranchChange,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchProps) => {
|
||||||
|
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||||
|
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||||
|
|
||||||
|
const handleBranchChange = useCallback(
|
||||||
|
(newBranch: number) => {
|
||||||
|
setCurrentBranch(newBranch);
|
||||||
|
onBranchChange?.(newBranch);
|
||||||
|
},
|
||||||
|
[onBranchChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToPrevious = useCallback(() => {
|
||||||
|
const newBranch =
|
||||||
|
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||||
|
handleBranchChange(newBranch);
|
||||||
|
}, [currentBranch, branches.length, handleBranchChange]);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
const newBranch =
|
||||||
|
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||||
|
handleBranchChange(newBranch);
|
||||||
|
}, [currentBranch, branches.length, handleBranchChange]);
|
||||||
|
|
||||||
|
const contextValue = useMemo<MessageBranchContextType>(
|
||||||
|
() => ({
|
||||||
|
branches,
|
||||||
|
currentBranch,
|
||||||
|
goToNext,
|
||||||
|
goToPrevious,
|
||||||
|
setBranches,
|
||||||
|
totalBranches: branches.length,
|
||||||
|
}),
|
||||||
|
[branches, currentBranch, goToNext, goToPrevious]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBranchContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MessageBranchContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const MessageBranchContent = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchContentProps) => {
|
||||||
|
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||||
|
const childrenArray = useMemo(
|
||||||
|
() => (Array.isArray(children) ? children : [children]),
|
||||||
|
[children]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use useEffect to update branches when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (branches.length !== childrenArray.length) {
|
||||||
|
setBranches(childrenArray);
|
||||||
|
}
|
||||||
|
}, [childrenArray, branches, setBranches]);
|
||||||
|
|
||||||
|
return childrenArray.map((branch, index) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||||
|
index === currentBranch ? "block" : "hidden"
|
||||||
|
)}
|
||||||
|
key={branch.key}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{branch}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
|
||||||
|
|
||||||
|
export const MessageBranchSelector = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchSelectorProps) => {
|
||||||
|
const { totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
// Don't render if there's only one branch
|
||||||
|
if (totalBranches <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup
|
||||||
|
className={cn(
|
||||||
|
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
orientation="horizontal"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const MessageBranchPrevious = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchPreviousProps) => {
|
||||||
|
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label="Previous branch"
|
||||||
|
disabled={totalBranches <= 1}
|
||||||
|
onClick={goToPrevious}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronLeftIcon size={14} />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const MessageBranchNext = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchNextProps) => {
|
||||||
|
const { goToNext, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label="Next branch"
|
||||||
|
disabled={totalBranches <= 1}
|
||||||
|
onClick={goToNext}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRightIcon size={14} />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||||
|
|
||||||
|
export const MessageBranchPage = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchPageProps) => {
|
||||||
|
const { currentBranch, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroupText
|
||||||
|
className={cn(
|
||||||
|
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{currentBranch + 1} of {totalBranches}
|
||||||
|
</ButtonGroupText>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||||
|
|
||||||
|
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||||
|
|
||||||
|
export const MessageResponse = memo(
|
||||||
|
({ className, ...props }: MessageResponseProps) => (
|
||||||
|
<Streamdown
|
||||||
|
className={cn(
|
||||||
|
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
plugins={streamdownPlugins}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.children === nextProps.children &&
|
||||||
|
nextProps.isAnimating === prevProps.isAnimating
|
||||||
|
);
|
||||||
|
|
||||||
|
MessageResponse.displayName = "MessageResponse";
|
||||||
|
|
||||||
|
export type MessageToolbarProps = ComponentProps<"div">;
|
||||||
|
|
||||||
|
export const MessageToolbar = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageToolbarProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-4 flex w-full items-center justify-between gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
173
packages/ui/src/components/ai-elements/tool.tsx
Normal file
173
packages/ui/src/components/ai-elements/tool.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@code/ui/components/badge";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@code/ui/components/collapsible";
|
||||||
|
import { cn } from "@code/ui/lib/utils";
|
||||||
|
import type { DynamicToolUIPart, ToolUIPart } from "ai";
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
CircleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
|
||||||
|
import { CodeBlock } from "./code-block";
|
||||||
|
|
||||||
|
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||||
|
|
||||||
|
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||||
|
<Collapsible
|
||||||
|
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ToolPart = ToolUIPart | DynamicToolUIPart;
|
||||||
|
|
||||||
|
export type ToolHeaderProps = {
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
} & (
|
||||||
|
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
|
||||||
|
| {
|
||||||
|
type: DynamicToolUIPart["type"];
|
||||||
|
state: DynamicToolUIPart["state"];
|
||||||
|
toolName: string;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusLabels: Record<ToolPart["state"], string> = {
|
||||||
|
"approval-requested": "Awaiting Approval",
|
||||||
|
"approval-responded": "Responded",
|
||||||
|
"input-available": "Running",
|
||||||
|
"input-streaming": "Pending",
|
||||||
|
"output-available": "Completed",
|
||||||
|
"output-denied": "Denied",
|
||||||
|
"output-error": "Error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcons: Record<ToolPart["state"], ReactNode> = {
|
||||||
|
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||||
|
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||||
|
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||||
|
"input-streaming": <CircleIcon className="size-4" />,
|
||||||
|
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||||
|
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||||
|
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStatusBadge = (status: ToolPart["state"]) => (
|
||||||
|
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||||
|
{statusIcons[status]}
|
||||||
|
{statusLabels[status]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ToolHeader = ({
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
state,
|
||||||
|
toolName,
|
||||||
|
...props
|
||||||
|
}: ToolHeaderProps) => {
|
||||||
|
const derivedName =
|
||||||
|
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-4 p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-sm">{title ?? derivedName}</span>
|
||||||
|
{getStatusBadge(state)}
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||||
|
|
||||||
|
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||||
|
<CollapsibleContent
|
||||||
|
className={cn(
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ToolInputProps = ComponentProps<"div"> & {
|
||||||
|
input: ToolPart["input"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||||
|
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
|
||||||
|
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Parameters
|
||||||
|
</h4>
|
||||||
|
<div className="rounded-md bg-muted/50">
|
||||||
|
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||||
|
output: ToolPart["output"];
|
||||||
|
errorText: ToolPart["errorText"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToolOutput = ({
|
||||||
|
className,
|
||||||
|
output,
|
||||||
|
errorText,
|
||||||
|
...props
|
||||||
|
}: ToolOutputProps) => {
|
||||||
|
if (!(output || errorText)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Output = <div>{output as ReactNode}</div>;
|
||||||
|
|
||||||
|
if (typeof output === "object" && !isValidElement(output)) {
|
||||||
|
Output = (
|
||||||
|
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||||
|
);
|
||||||
|
} else if (typeof output === "string") {
|
||||||
|
Output = <CodeBlock code={output} language="json" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-2", className)} {...props}>
|
||||||
|
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
{errorText ? "Error" : "Result"}
|
||||||
|
</h4>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||||
|
errorText
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "bg-muted/50 text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{errorText && <div>{errorText}</div>}
|
||||||
|
{Output}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
packages/ui/src/components/badge.tsx
Normal file
51
packages/ui/src/components/badge.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props";
|
||||||
|
import { useRender } from "@base-ui/react/use-render";
|
||||||
|
import { cn } from "@code/ui/lib/utils";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-none border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Badge = ({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) =>
|
||||||
|
useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
82
packages/ui/src/components/button-group.tsx
Normal file
82
packages/ui/src/components/button-group.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props";
|
||||||
|
import { useRender } from "@base-ui/react/use-render";
|
||||||
|
import { Separator } from "@code/ui/components/separator";
|
||||||
|
import { cn } from "@code/ui/lib/utils";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"m-0 flex min-w-0 w-fit items-stretch rounded-none border-0 p-0 *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-none [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
{
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"*:data-slot:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||||
|
vertical:
|
||||||
|
"flex-col *:data-slot:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const ButtonGroup = ({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"fieldset"> &
|
||||||
|
VariantProps<typeof buttonGroupVariants>) => (
|
||||||
|
<fieldset
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ButtonGroupText = ({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"div">) =>
|
||||||
|
useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps<"div">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"flex items-center gap-2 rounded-none border bg-muted px-2.5 text-xs font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "button-group-text",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ButtonGroupSeparator = ({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) => (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
};
|
||||||
17
packages/ui/src/components/collapsible.tsx
Normal file
17
packages/ui/src/components/collapsible.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
|
||||||
|
|
||||||
|
const Collapsible = ({ ...props }: CollapsiblePrimitive.Root.Props) => (
|
||||||
|
<CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const CollapsibleTrigger = ({
|
||||||
|
...props
|
||||||
|
}: CollapsiblePrimitive.Trigger.Props) => (
|
||||||
|
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const CollapsibleContent = ({ ...props }: CollapsiblePrimitive.Panel.Props) => (
|
||||||
|
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
363
packages/ui/src/components/mobile-chat.tsx
Normal file
363
packages/ui/src/components/mobile-chat.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
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 MobileChatComposerDockProps extends ComponentProps<"div"> {
|
||||||
|
errorMessage?: string;
|
||||||
|
statusMessage?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileChatComposerDock = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
errorMessage,
|
||||||
|
statusMessage,
|
||||||
|
...props
|
||||||
|
}: MobileChatComposerDockProps) => {
|
||||||
|
const message = errorMessage ?? statusMessage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="mobile-chat-composer-dock"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 border-t border-[#e8e8e6] bg-[#fefefe] pb-[env(safe-area-inset-bottom)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{message ? (
|
||||||
|
<div
|
||||||
|
id={errorMessage ? "composer-error" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"px-3 pt-2 text-center text-[10px] leading-4 text-[#777875]",
|
||||||
|
errorMessage && "text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileChatComposerDock
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
statusMessage={statusMessage}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
aria-label="Send a message"
|
||||||
|
className={cn("w-full px-3 pt-[11px] pb-3", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</MobileChatComposerDock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
MobileChatAssistantLabel,
|
||||||
|
MobileChatBubble,
|
||||||
|
MobileChatComposer,
|
||||||
|
MobileChatComposerDock,
|
||||||
|
MobileChatHeader,
|
||||||
|
MobileChatMark,
|
||||||
|
MobileChatMessage,
|
||||||
|
MobileChatToolResult,
|
||||||
|
MobileChatToolCall,
|
||||||
|
MobileViewport,
|
||||||
|
};
|
||||||
|
export type {
|
||||||
|
MobileChatAssistantLabelProps,
|
||||||
|
MobileChatBubbleProps,
|
||||||
|
MobileChatComposerProps,
|
||||||
|
MobileChatComposerDockProps,
|
||||||
|
MobileChatHeaderProps,
|
||||||
|
MobileChatMessageProps,
|
||||||
|
MobileChatToolCallProps,
|
||||||
|
MobileChatToolResultProps,
|
||||||
|
};
|
||||||
16
packages/ui/src/components/mobile-product.tsx
Normal file
16
packages/ui/src/components/mobile-product.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export {
|
||||||
|
MobileAssistantChatScreen,
|
||||||
|
MobileExpandedWorkScreen,
|
||||||
|
MobileHomeScreen,
|
||||||
|
MobileWorkListScreen,
|
||||||
|
MobileWorkUnitDetailScreen,
|
||||||
|
} from "./mobile-workspace/index";
|
||||||
|
export type {
|
||||||
|
MobileAssistantMessageView,
|
||||||
|
MobileAssistantView,
|
||||||
|
MobileSignalView,
|
||||||
|
MobileWorkspaceScreenProps,
|
||||||
|
MobileWorkspaceView,
|
||||||
|
MobileWorkUnitTone,
|
||||||
|
MobileWorkUnitView,
|
||||||
|
} from "./mobile-workspace/index";
|
||||||
1209
packages/ui/src/components/mobile-workspace-screens.tsx
Normal file
1209
packages/ui/src/components/mobile-workspace-screens.tsx
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/ui/src/components/mobile-workspace/index.ts
Normal file
14
packages/ui/src/components/mobile-workspace/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export { MobileAssistantChatScreen } from "./mobile-assistant-chat-screen";
|
||||||
|
export { MobileExpandedWorkScreen } from "./mobile-expanded-work-screen";
|
||||||
|
export { MobileHomeScreen } from "./mobile-home-screen";
|
||||||
|
export { MobileWorkListScreen } from "./mobile-work-list-screen";
|
||||||
|
export { MobileWorkUnitDetailScreen } from "./mobile-work-unit-detail-screen";
|
||||||
|
export type {
|
||||||
|
MobileAssistantMessageView,
|
||||||
|
MobileAssistantView,
|
||||||
|
MobileSignalView,
|
||||||
|
MobileWorkspaceScreenProps,
|
||||||
|
MobileWorkspaceView,
|
||||||
|
MobileWorkUnitTone,
|
||||||
|
MobileWorkUnitView,
|
||||||
|
} from "./types";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
|
||||||
|
import type { MobileWorkspaceScreenProps } from "./types";
|
||||||
|
|
||||||
|
export const MobileAssistantChatScreen = (
|
||||||
|
props: MobileWorkspaceScreenProps
|
||||||
|
) => <MobileWorkspaceScreenRenderer {...props} variant="character-pass" />;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
|
||||||
|
import type { MobileWorkspaceScreenProps } from "./types";
|
||||||
|
|
||||||
|
export const MobileExpandedWorkScreen = (props: MobileWorkspaceScreenProps) => (
|
||||||
|
<MobileWorkspaceScreenRenderer {...props} variant="home-expanded-in-place" />
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
|
||||||
|
import type { MobileWorkspaceScreenProps } from "./types";
|
||||||
|
|
||||||
|
export const MobileHomeScreen = (props: MobileWorkspaceScreenProps) => (
|
||||||
|
<MobileWorkspaceScreenRenderer {...props} variant="work-units-home" />
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
|
||||||
|
import type { MobileWorkspaceScreenProps } from "./types";
|
||||||
|
|
||||||
|
export const MobileWorkListScreen = (props: MobileWorkspaceScreenProps) => (
|
||||||
|
<MobileWorkspaceScreenRenderer {...props} variant="vertical-work-stack" />
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
|
||||||
|
import type { MobileWorkspaceScreenProps } from "./types";
|
||||||
|
|
||||||
|
export const MobileWorkUnitDetailScreen = (
|
||||||
|
props: MobileWorkspaceScreenProps
|
||||||
|
) => <MobileWorkspaceScreenRenderer {...props} variant="expanded-work-unit" />;
|
||||||
50
packages/ui/src/components/mobile-workspace/models.ts
Normal file
50
packages/ui/src/components/mobile-workspace/models.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export type MobileWorkUnitTone = "blue" | "green" | "orange" | "purple";
|
||||||
|
|
||||||
|
export interface MobileWorkUnitView {
|
||||||
|
readonly artifactCount: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly id: string;
|
||||||
|
readonly nextAction: string;
|
||||||
|
readonly progress: number;
|
||||||
|
readonly statusLabel: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly tone: MobileWorkUnitTone;
|
||||||
|
readonly updatedLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileSignalView {
|
||||||
|
readonly desiredOutcome: string;
|
||||||
|
readonly id: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileAssistantMessageView {
|
||||||
|
readonly id: string;
|
||||||
|
readonly role: "assistant" | "user";
|
||||||
|
readonly text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileAssistantView {
|
||||||
|
readonly isBusy: boolean;
|
||||||
|
readonly messages: readonly MobileAssistantMessageView[];
|
||||||
|
readonly statusLabel: string;
|
||||||
|
readonly statusTone: "amber" | "green" | "red";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileWorkspaceView {
|
||||||
|
readonly activeCount: number;
|
||||||
|
readonly activityCount: number;
|
||||||
|
readonly artifactCount: number;
|
||||||
|
readonly assistant: MobileAssistantView;
|
||||||
|
readonly isLoading: boolean;
|
||||||
|
readonly latestSignal?: MobileSignalView;
|
||||||
|
readonly needsAttentionCount: number;
|
||||||
|
readonly organizationLabel: string;
|
||||||
|
readonly projectName?: string;
|
||||||
|
readonly selectedWorkUnit?: MobileWorkUnitView;
|
||||||
|
readonly shippedCount: number;
|
||||||
|
readonly totalCount: number;
|
||||||
|
readonly workUnits: readonly MobileWorkUnitView[];
|
||||||
|
}
|
||||||
15
packages/ui/src/components/mobile-workspace/types.ts
Normal file
15
packages/ui/src/components/mobile-workspace/types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MobileWorkspaceRendererProps } from "../mobile-workspace-screens";
|
||||||
|
|
||||||
|
export type MobileWorkspaceScreenProps = Omit<
|
||||||
|
MobileWorkspaceRendererProps,
|
||||||
|
"variant"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MobileAssistantMessageView,
|
||||||
|
MobileAssistantView,
|
||||||
|
MobileSignalView,
|
||||||
|
MobileWorkspaceView,
|
||||||
|
MobileWorkUnitTone,
|
||||||
|
MobileWorkUnitView,
|
||||||
|
} from "./models";
|
||||||
183
packages/ui/src/components/select.tsx
Normal file
183
packages/ui/src/components/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select";
|
||||||
|
import { cn } from "@code/ui/lib/utils";
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = ({ className, ...props }: SelectPrimitive.Group.Props) => (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectValue = ({ className, ...props }: SelectPrimitive.Value.Props) => (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectTrigger = ({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-none border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectScrollUpButton = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) => (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectScrollDownButton = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) => (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectContent = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn(
|
||||||
|
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectLabel = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) => (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-2 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectItem = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectSeparator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: "Inter Variable", sans-serif;
|
--font-sans: "Inter", sans-serif;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
|||||||
Reference in New Issue
Block a user