diff --git a/apps/web/components.json b/apps/web/components.json index 7a96948..4d83704 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -12,7 +12,7 @@ }, "iconLibrary": "lucide", "aliases": { - "components": "@/components", + "components": "@code/ui/components", "utils": "@code/ui/lib/utils", "ui": "@code/ui/components", "lib": "@/lib", diff --git a/apps/web/package.json b/apps/web/package.json index 55f3aab..bc779f4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "dev": "react-router dev", "dev:tailscale": "react-router dev --host 0.0.0.0", "start": "react-router-serve ./build/server/index.js", - "typecheck": "react-router typegen && tsc" + "check-types": "react-router typegen && tsc --noEmit" }, "dependencies": { "@code/auth": "workspace:*", diff --git a/apps/web/src/components/chat/assistant-identity.tsx b/apps/web/src/components/chat/assistant-identity.tsx index 05f6314..47038ef 100644 --- a/apps/web/src/components/chat/assistant-identity.tsx +++ b/apps/web/src/components/chat/assistant-identity.tsx @@ -1,5 +1,4 @@ -import { MessageHeader } from "@code/ui/components/message"; -import { Sparkles } from "lucide-react"; +import { MobileChatAssistantLabel } from "@code/ui/components/mobile-chat"; import type { AssistantResponseState } from "@/lib/chat/types"; @@ -8,20 +7,18 @@ interface AssistantIdentityProps { } export const AssistantIdentity = ({ state }: AssistantIdentityProps) => ( - - - - - Zopu - {state && ( - - - {state === "thinking" ? "Thinking" : "Writing"} - - )} - + ) : undefined + } + /> ); diff --git a/apps/web/src/components/chat/chat-composer.tsx b/apps/web/src/components/chat/chat-composer.tsx index 6c0fad8..2276af5 100644 --- a/apps/web/src/components/chat/chat-composer.tsx +++ b/apps/web/src/components/chat/chat-composer.tsx @@ -1,10 +1,5 @@ -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupTextarea, -} from "@code/ui/components/input-group"; -import { CircleAlert, LoaderCircle, Send } from "lucide-react"; +import { MobileChatComposer } from "@code/ui/components/mobile-chat"; +import { LoaderCircle } from "lucide-react"; import type { ReactNode } from "react"; import { useChatComposer } from "@/hooks/chat/use-chat-composer"; @@ -15,90 +10,31 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => { status === "connecting" || status === "submitted" || status === "streaming"; const composer = useChatComposer({ busy, onSend }); - let statusContent: ReactNode = ( - - Enter to send · Shift + Enter for a new line - - ); + let statusMessage: ReactNode; if (busy) { - statusContent = ( - <> - + statusMessage = ( + + {status === "connecting" ? "Preparing Zopu" : "Zopu is responding"} - - ); - } - if (error) { - statusContent = ( - - - {error.message || "Message failed to send"} ); } return ( -
-
- - - - - {busy ? ( - - ) : ( - - )} - - - -
- {composer.isFocused || busy || error ? ( - {statusContent} - ) : ( - Zopu can make mistakes. Check important information. - )} -
-
-
+ ); }; diff --git a/apps/web/src/components/chat/chat-conversation.tsx b/apps/web/src/components/chat/chat-conversation.tsx index 686e675..98f9c59 100644 --- a/apps/web/src/components/chat/chat-conversation.tsx +++ b/apps/web/src/components/chat/chat-conversation.tsx @@ -1,15 +1,6 @@ +import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation"; import { Button } from "@code/ui/components/button"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@code/ui/components/empty"; -import { MessageGroup } from "@code/ui/components/message"; -import { MessageScrollerItem } from "@code/ui/components/message-scroller"; -import { Sparkles } from "lucide-react"; +import { MobileChatMark } from "@code/ui/components/mobile-chat"; import { SUGGESTIONS } from "@/lib/chat/constants"; import type { ChatConversationProps } from "@/lib/chat/types"; @@ -25,23 +16,21 @@ export const ChatConversation = ({ }: ChatConversationProps) => { if (historyReady && messages.length === 0) { return ( - - - - - - + + +
+

What can I help with? - - +

+

Think, write, plan, or explore an idea with Zopu. - - - +

+
+
{SUGGESTIONS.map(([title, prompt]) => ( ))} - - +
+
); } return ( - - {messages.map((message, index) => ( - - - +
+ {messages.map((message) => ( + ))} - {status === "submitted" && ( - - - - )} - + {status === "submitted" && } +
); }; diff --git a/apps/web/src/components/chat/chat-header.tsx b/apps/web/src/components/chat/chat-header.tsx index d2bb0fd..eca9509 100644 --- a/apps/web/src/components/chat/chat-header.tsx +++ b/apps/web/src/components/chat/chat-header.tsx @@ -1,32 +1,11 @@ -import { Sparkles } from "lucide-react"; +import { MobileChatHeader } from "@code/ui/components/mobile-chat"; -import { MODEL_LABEL, STATUS_COPY } from "@/lib/chat/constants"; -import { getStatusDotClass } from "@/lib/chat/transforms"; +import { STATUS_COPY } from "@/lib/chat/constants"; import type { ChatHeaderProps } from "@/lib/chat/types"; export const ChatHeader = ({ status }: ChatHeaderProps) => ( -
-
-
-
- -
-
-

- Zopu -

-

-

-
-
-

- {MODEL_LABEL} -

-
-
+ ); diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx index 71d0235..36e0ecf 100644 --- a/apps/web/src/components/chat/chat-message.tsx +++ b/apps/web/src/components/chat/chat-message.tsx @@ -1,7 +1,13 @@ -import { Bubble, BubbleContent } from "@code/ui/components/bubble"; -import { Message, MessageContent } from "@code/ui/components/message"; +import { + Message, + MessageContent, + MessageResponse, +} from "@code/ui/components/ai-elements/message"; +import { + MobileChatBubble, + MobileChatMessage, +} from "@code/ui/components/mobile-chat"; import type { ReactNode } from "react"; -import { Streamdown } from "streamdown"; import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms"; import type { ChatMessageProps } from "@/lib/chat/types"; @@ -21,13 +27,13 @@ export const ChatMessage = ({ message }: ChatMessageProps) => { let content: ReactNode = text; if (!isUser) { content = text ? ( - {text} - + ) : (
@@ -37,41 +43,29 @@ export const ChatMessage = ({ message }: ChatMessageProps) => { ); } + const sender = isUser ? "user" : "assistant"; + return ( - - - {!isUser && ( - - )} - - - {message.parts.map((part) => - part.type === "dynamic-tool" ? ( - - ) : null + + + +
+ {!isUser && ( + )} - {content} - - + + {message.parts.map((part) => + part.type === "dynamic-tool" ? ( + + ) : null + )} + {content} + +
+
); diff --git a/apps/web/src/components/chat/chat-page.tsx b/apps/web/src/components/chat/chat-page.tsx index 6b4ca76..7d08176 100644 --- a/apps/web/src/components/chat/chat-page.tsx +++ b/apps/web/src/components/chat/chat-page.tsx @@ -1,10 +1,8 @@ import { - MessageScroller, - MessageScrollerButton, - MessageScrollerContent, - MessageScrollerProvider, - MessageScrollerViewport, -} from "@code/ui/components/message-scroller"; + Conversation, + ConversationContent, + ConversationScrollButton, +} from "@code/ui/components/ai-elements/conversation"; import { useChatAgent } from "@/hooks/chat/use-chat-agent"; @@ -17,36 +15,24 @@ export const ChatPage = () => { const handleSendMessage = (message: string) => agent.sendMessage(message); return ( -
+
- - - - - - void handleSendMessage(suggestion) - } - status={agent.status} - /> - - - + + void handleSendMessage(suggestion)} + status={agent.status} /> - - + + +
( - - - -
- - - -
+ + + +
+ + +
+ + + +
+
+
+
); diff --git a/apps/web/src/components/chat/chat-tool-call.tsx b/apps/web/src/components/chat/chat-tool-call.tsx index ab98098..37a87c8 100644 --- a/apps/web/src/components/chat/chat-tool-call.tsx +++ b/apps/web/src/components/chat/chat-tool-call.tsx @@ -1,4 +1,11 @@ -import { Check, LoaderCircle, Terminal, TriangleAlert } from "lucide-react"; +import { + Tool, + ToolContent, + ToolInput, + ToolOutput, +} from "@code/ui/components/ai-elements/tool"; +import { MobileChatToolCall } from "@code/ui/components/mobile-chat"; +import { Search, SquareTerminal } from "lucide-react"; import type { ChatToolCallProps } from "@/lib/chat/types"; @@ -7,35 +14,47 @@ export const ChatToolCall = ({ part }: ChatToolCallProps) => { typeof part.input === "string" ? part.input : JSON.stringify(part.input, null, 2); - let Icon = LoaderCircle; - let status = "Running"; + let status = "running"; + let tone: "error" | "neutral" | "success" = "neutral"; + if (part.state === "output-available") { detail = typeof part.output === "string" ? part.output : JSON.stringify(part.output, null, 2); - Icon = Check; - status = "Completed"; + status = "done"; + tone = "success"; } else if (part.state === "output-error") { detail = part.errorText; - Icon = TriangleAlert; - status = "Failed"; + status = "failed"; + tone = "error"; } - const isRunning = part.state === "input-available"; + + const isSearch = part.toolName.toLowerCase().includes("search"); + const output = part.state === "output-available" ? part.output : undefined; + const errorText = part.state === "output-error" ? part.errorText : undefined; return ( -
- - - - {part.toolName} - - {status} - - -
-        {detail}
-      
-
+ + + + ) : ( + + ) + } + status={status} + tone={tone} + toolName={part.toolName} + /> +
+ + +
+
+
); }; diff --git a/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx new file mode 100644 index 0000000..f692be0 --- /dev/null +++ b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx @@ -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 ; + } + if (screen === "home") { + return ; + } + if (screen === "work-unit-detail") { + return ; + } + if (workspace.expanded) { + return ; + } + return ; +}; diff --git a/apps/web/src/hooks/chat/use-chat-agent.ts b/apps/web/src/hooks/chat/use-chat-agent.ts index e90fb25..77eeb2a 100644 --- a/apps/web/src/hooks/chat/use-chat-agent.ts +++ b/apps/web/src/hooks/chat/use-chat-agent.ts @@ -1,13 +1,13 @@ import { useFlueAgent } from "@flue/react"; import { usePersonalOrganization } from "@/hooks/use-personal-organization"; +import type { PersonalOrganizationState } from "@/hooks/use-personal-organization"; import { CHAT_AGENT } from "@/lib/chat/constants"; import type { ChatAgentState } from "@/lib/chat/types"; -export const useChatAgent = (): ChatAgentState => { - // The agent session is not created until the authenticated user's personal - // organization has been ensured and its id is available. - const organization = usePersonalOrganization(); +export const useOrganizationChatAgent = ( + organization: PersonalOrganizationState +): ChatAgentState => { const agent = useFlueAgent({ ...CHAT_AGENT, id: organization.organizationId, @@ -36,3 +36,6 @@ export const useChatAgent = (): ChatAgentState => { status, }; }; + +export const useChatAgent = (): ChatAgentState => + useOrganizationChatAgent(usePersonalOrganization()); diff --git a/apps/web/src/hooks/use-mobile-project-workspace.ts b/apps/web/src/hooks/use-mobile-project-workspace.ts new file mode 100644 index 0000000..168fe82 --- /dev/null +++ b/apps/web/src/hooks/use-mobile-project-workspace.ts @@ -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["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["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; +}; diff --git a/apps/web/src/hooks/use-mobile-workspace.ts b/apps/web/src/hooks/use-mobile-workspace.ts new file mode 100644 index 0000000..7fdfb2f --- /dev/null +++ b/apps/web/src/hooks/use-mobile-workspace.ts @@ -0,0 +1,52 @@ +import type { FormEvent } from "react"; +import { useState } from "react"; + +interface UseMobileWorkspaceOptions { + readonly initialExpanded?: boolean; + readonly onSend: (message: string) => Promise; +} + +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(); + + const handleComposerChange = (value: string) => { + setComposerValue(value); + setStatusMessage(undefined); + }; + + const handleComposerSubmit = (event: FormEvent) => { + 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, + }; +}; diff --git a/apps/web/src/hooks/use-personal-organization.ts b/apps/web/src/hooks/use-personal-organization.ts index 5b1feae..3eca937 100644 --- a/apps/web/src/hooks/use-personal-organization.ts +++ b/apps/web/src/hooks/use-personal-organization.ts @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; export interface PersonalOrganizationState { readonly error?: Error; readonly organizationId?: Id<"organizations">; + readonly organizationName?: string; } /** Ensure the authenticated user has its personal tenancy boundary. */ @@ -16,10 +17,12 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { const ensurePersonalOrganization = useMutation( api.organizations.ensurePersonalOrganization ); - const [organizationId, setOrganizationId] = useState< - Id<"organizations"> | undefined - >(); - const [error, setError] = useState(); + const [state, setState] = useState<{ + readonly error?: Error; + readonly organizationId?: Id<"organizations">; + readonly organizationName?: string; + readonly userId: string; + }>(); useEffect(() => { if (!userId) { @@ -31,16 +34,21 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { try { const organization = await ensurePersonalOrganization(); if (active) { - setOrganizationId(organization._id); - setError(undefined); + setState({ + organizationId: organization._id, + organizationName: organization.name, + userId, + }); } } catch (caughtError: unknown) { if (active) { - setError( - caughtError instanceof Error - ? caughtError - : new Error(String(caughtError)) - ); + setState({ + error: + caughtError instanceof Error + ? caughtError + : new Error(String(caughtError)), + userId, + }); } } }; @@ -51,9 +59,13 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { }; }, [ensurePersonalOrganization, userId]); - if (!userId) { + if (!userId || state?.userId !== userId) { return {}; } - return { error, organizationId }; + return { + error: state.error, + organizationId: state.organizationId, + organizationName: state.organizationName, + }; }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d8ef689..779a563 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -4,49 +4,10 @@ html, body { min-height: 100%; - overflow: hidden; } -.chat-surface { - position: relative; - isolation: isolate; - background: - radial-gradient( - circle at 50% -12%, - oklch(0.72 0.045 255 / 9%), - transparent 32rem - ), - var(--background); -} - -.chat-surface::before { - position: absolute; - z-index: -1; - inset: 0; - background-image: radial-gradient( - oklch(0.5 0 0 / 9%) 0.55px, - transparent 0.55px - ); - background-size: 18px 18px; - mask-image: linear-gradient(to bottom, black, transparent 42%); - content: ""; - pointer-events: none; -} - -.chat-header { - background: color-mix(in oklch, var(--background) 88%, transparent); - backdrop-filter: blur(18px) saturate(1.25); -} - -.zopu-mark { - border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent); - background: - linear-gradient(145deg, oklch(1 0 0 / 65%), transparent 55%), - color-mix(in oklch, var(--muted) 88%, var(--background)); - color: var(--foreground); - box-shadow: - inset 0 1px 0 oklch(1 0 0 / 48%), - 0 6px 18px -12px oklch(0 0 0 / 55%); +.ai-conversation-mobile > div { + scrollbar-gutter: auto !important; } .chat-message { @@ -120,15 +81,6 @@ body { } } -.composer-dock { - position: relative; - background: linear-gradient(to top, var(--background) 68%, transparent); -} - -.composer-shell { - transform: translateZ(0); -} - @keyframes chat-message-enter { from { opacity: 0; diff --git a/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts b/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts new file mode 100644 index 0000000..072de19 --- /dev/null +++ b/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts @@ -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; +type ProjectView = FunctionReturnType[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, + }; +}; diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index eb98fe3..f24caa7 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -68,7 +68,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => ( - + {children} @@ -81,13 +81,12 @@ const App = () => ( -
- -
+
diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index 30b5e6f..0898540 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -1,4 +1,19 @@ +import { index, layout, route } from "@react-router/dev/routes"; import type { RouteConfig } from "@react-router/dev/routes"; -import { flatRoutes } from "@react-router/fs-routes"; -export default flatRoutes() satisfies RouteConfig; +export default [ + layout("./routes/auth/layout.tsx", [ + route("login", "./routes/auth/login/page.tsx"), + route("signup", "./routes/auth/signup/page.tsx"), + ]), + layout("./routes/app/layout.tsx", [ + layout("./routes/app/mobile/layout.tsx", [ + index("./routes/app/mobile/page.tsx"), + route("chat", "./routes/app/mobile/chat/page.tsx"), + route("work", "./routes/app/mobile/work-list/page.tsx"), + route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"), + ]), + route("dashboard", "./routes/app/dashboard/page.tsx"), + route("todos", "./routes/app/todos/page.tsx"), + ]), +] satisfies RouteConfig; diff --git a/apps/web/src/routes/_app._index.tsx b/apps/web/src/routes/_app._index.tsx deleted file mode 100644 index 9b7eaec..0000000 --- a/apps/web/src/routes/_app._index.tsx +++ /dev/null @@ -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 = () => ; - -export default Home; diff --git a/apps/web/src/routes/_app.todos.tsx b/apps/web/src/routes/_app.todos.tsx deleted file mode 100644 index 9bc6699..0000000 --- a/apps/web/src/routes/_app.todos.tsx +++ /dev/null @@ -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) => { - 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 ( -
- - - Todo List - Manage your tasks efficiently - - -
- setNewTodoText(e.target.value)} - placeholder="Add a new task..." - /> - -
- - {todos === undefined ? ( -
- -
- ) : todos.length === 0 ? ( -

No todos yet. Add one above!

- ) : ( -
    - {todos.map((todo) => ( -
  • -
    - handleToggleTodo(todo._id, todo.completed)} - id={`todo-${todo._id}`} - /> - -
    - -
  • - ))} -
- )} -
-
-
- ); -} diff --git a/apps/web/src/routes/_app.dashboard.tsx b/apps/web/src/routes/app/dashboard/page.tsx similarity index 100% rename from apps/web/src/routes/_app.dashboard.tsx rename to apps/web/src/routes/app/dashboard/page.tsx diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/app/layout.tsx similarity index 100% rename from apps/web/src/routes/_app.tsx rename to apps/web/src/routes/app/layout.tsx diff --git a/apps/web/src/routes/app/mobile/chat/page.tsx b/apps/web/src/routes/app/mobile/chat/page.tsx new file mode 100644 index 0000000..1326aab --- /dev/null +++ b/apps/web/src/routes/app/mobile/chat/page.tsx @@ -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 ; +} diff --git a/apps/web/src/routes/app/mobile/layout.tsx b/apps/web/src/routes/app/mobile/layout.tsx new file mode 100644 index 0000000..ae63b03 --- /dev/null +++ b/apps/web/src/routes/app/mobile/layout.tsx @@ -0,0 +1,9 @@ +import { Outlet } from "react-router"; + +export default function MobileProductLayout() { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/routes/app/mobile/page.tsx b/apps/web/src/routes/app/mobile/page.tsx new file mode 100644 index 0000000..4e058d8 --- /dev/null +++ b/apps/web/src/routes/app/mobile/page.tsx @@ -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 ; +} diff --git a/apps/web/src/routes/app/mobile/work-list/page.tsx b/apps/web/src/routes/app/mobile/work-list/page.tsx new file mode 100644 index 0000000..95421d2 --- /dev/null +++ b/apps/web/src/routes/app/mobile/work-list/page.tsx @@ -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 ; +} diff --git a/apps/web/src/routes/app/mobile/work/page.tsx b/apps/web/src/routes/app/mobile/work/page.tsx new file mode 100644 index 0000000..27a88fb --- /dev/null +++ b/apps/web/src/routes/app/mobile/work/page.tsx @@ -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 ; +} diff --git a/apps/web/src/routes/app/todos/page.tsx b/apps/web/src/routes/app/todos/page.tsx new file mode 100644 index 0000000..ce4be3c --- /dev/null +++ b/apps/web/src/routes/app/todos/page.tsx @@ -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) => { + 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 = ( +
+ +
+ ); + } else if (todos.length === 0) { + todoContent = ( +

No todos yet. Add one above!

+ ); + } else { + todoContent = ( +
    + {todos.map((todo) => ( +
  • +
    + + handleToggleTodo(todo._id, todo.completed) + } + /> + +
    + +
  • + ))} +
+ ); + } + + return ( +
+ + + Todo List + Manage your tasks efficiently + + +
+ setNewTodoText(event.target.value)} + placeholder="Add a new task..." + value={newTodoText} + /> + +
+ + {todoContent} +
+
+
+ ); +} diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/auth/layout.tsx similarity index 100% rename from apps/web/src/routes/_auth.tsx rename to apps/web/src/routes/auth/layout.tsx diff --git a/apps/web/src/routes/_auth.login.tsx b/apps/web/src/routes/auth/login/page.tsx similarity index 88% rename from apps/web/src/routes/_auth.login.tsx rename to apps/web/src/routes/auth/login/page.tsx index 676bdf9..167dfcc 100644 --- a/apps/web/src/routes/_auth.login.tsx +++ b/apps/web/src/routes/auth/login/page.tsx @@ -1,7 +1,7 @@ import { LoginForm } from "@code/auth/web"; import { useNavigate } from "react-router"; -import type { Route } from "./+types/_auth.login"; +import type { Route } from "./+types/page"; export const meta = (_args: Route.MetaArgs) => [ { title: "Sign in | Zopu" }, diff --git a/apps/web/src/routes/_auth.signup.tsx b/apps/web/src/routes/auth/signup/page.tsx similarity index 88% rename from apps/web/src/routes/_auth.signup.tsx rename to apps/web/src/routes/auth/signup/page.tsx index 4bb1dee..aad7dbe 100644 --- a/apps/web/src/routes/_auth.signup.tsx +++ b/apps/web/src/routes/auth/signup/page.tsx @@ -1,7 +1,7 @@ import { SignupForm } from "@code/auth/web"; import { useNavigate } from "react-router"; -import type { Route } from "./+types/_auth.signup"; +import type { Route } from "./+types/page"; export const meta = (_args: Route.MetaArgs) => [ { title: "Create account | Zopu" }, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2b39733..02947ff 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,12 +3,12 @@ import path from "node:path"; import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite-plus"; -import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ envDir: path.resolve(import.meta.dirname, "../.."), - plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], + plugins: [tailwindcss(), reactRouter()], resolve: { dedupe: ["convex", "react", "react-dom"], + tsconfigPaths: true, }, }); diff --git a/packages/backend/convex/projects.ts b/packages/backend/convex/projects.ts index a890aa9..b3881a5 100644 --- a/packages/backend/convex/projects.ts +++ b/packages/backend/convex/projects.ts @@ -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 => { 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 => { 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, diff --git a/packages/ui/package.json b/packages/ui/package.json index 05cd4ab..98890f4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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:*", diff --git a/packages/ui/src/components/ai-elements/code-block.tsx b/packages/ui/src/components/ai-elements/code-block.tsx new file mode 100644 index 0000000..e8118f6 --- /dev/null +++ b/packages/ui/src/components/ai-elements/code-block.tsx @@ -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 }) => ( + + {token.content} + +); + +// 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; +}) => ( + + {keyedLine.tokens.length === 0 + ? "\n" + : keyedLine.tokens.map(({ token, key }) => ( + + ))} + +); + +// Types +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +interface TokenizedCode { + tokens: ThemedToken[][]; + fg: string; + bg: string; +} + +interface CodeBlockContextType { + code: string; +} + +// Context +const CodeBlockContext = createContext({ + code: "", +}); + +// Highlighter cache (singleton per language) +const highlighterCache = new Map< + string, + Promise> +>(); + +// Token cache +const tokensCache = new Map(); + +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> => { + 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 => { + 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 ( +
+        
+          {keyedLines.map((keyedLine) => (
+            
+          ))}
+        
+      
+ ); + }, + (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 & { language: string }) => ( +
+); + +export const CodeBlockHeader = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockTitle = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockFilename = ({ + children, + className, + ...props +}: HTMLAttributes) => ( + + {children} + +); + +export const CodeBlockActions = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +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 ( +
+ +
+ ); +}; + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const contextValue = useMemo(() => ({ code }), [code]); + + return ( + + + {children} + + + + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + 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(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 ( + + ); +}; + +export type CodeBlockLanguageSelectorProps = ComponentProps; + +export const CodeBlockLanguageSelector = ( + props: CodeBlockLanguageSelectorProps +) =>