merge mobile workspace UI into integration
This commit is contained in:
@@ -12,7 +12,11 @@ import type { Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireCurrentOrganization } from "./authz";
|
||||
import { persistPublicGitImportTransaction } from "./projectStore";
|
||||
import {
|
||||
makeProjectMutationStore,
|
||||
makeProjectQueryStore,
|
||||
persistPublicGitImportTransaction,
|
||||
} from "./projectStore";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: persist a public Git import in one transaction
|
||||
@@ -146,7 +150,6 @@ export const putContextDocument = internalMutation({
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { makeProjectMutationStore } = await import("./projectStore");
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(args.userId, args.projectId, args.write as never);
|
||||
},
|
||||
@@ -160,7 +163,6 @@ export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<ProjectView[]> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectQueryStore } = await import("./projectStore");
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.listProjects(userId);
|
||||
},
|
||||
@@ -174,7 +176,6 @@ export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args): Promise<ProjectView | null> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectQueryStore } = await import("./projectStore");
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.getProject(userId, args.projectId);
|
||||
},
|
||||
@@ -216,7 +217,6 @@ export const putText = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectMutationStore } = await import("./projectStore");
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(userId, args.projectId, {
|
||||
_tag: "UserText" as const,
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.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",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "catalog:",
|
||||
@@ -23,9 +28,12 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"shadcn": "^4.12.0",
|
||||
"shiki": "^4.3.1",
|
||||
"sonner": "catalog:",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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 {
|
||||
--font-sans: "Inter Variable", sans-serif;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
Reference in New Issue
Block a user