From 68e0a564667dd5a8e84ed0ede109a9c78e3784fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Sans=C3=B3n?= Date: Mon, 29 Jul 2024 15:07:39 +0200 Subject: [PATCH] Document playground --- packages/compiler/src/compiler/chain.test.ts | 51 +--- packages/compiler/src/compiler/chain.ts | 21 +- packages/compiler/src/compiler/compile.ts | 15 +- packages/compiler/src/compiler/index.ts | 12 + .../src/compiler/readMetadata.test.ts | 15 ++ .../compiler/src/compiler/readMetadata.ts | 29 ++- packages/web-ui/package.json | 2 + packages/web-ui/src/ds/atoms/Badge/index.tsx | 2 + packages/web-ui/src/ds/atoms/Button/index.tsx | 22 +- .../src/ds/atoms/DropdownMenu/index.tsx | 40 +++- .../web-ui/src/ds/atoms/FormField/index.tsx | 1 + packages/web-ui/src/ds/atoms/Icons/index.tsx | 61 +++-- packages/web-ui/src/ds/atoms/Text/index.tsx | 2 +- .../ds/molecules/Chat/ChatTextArea/index.tsx | 55 +++++ .../ds/molecules/Chat/ErrorMessage/index.tsx | 29 +++ .../src/ds/molecules/Chat/Message/index.tsx | 48 ++++ .../ds/molecules/Chat/MessageList/index.tsx | 23 ++ .../web-ui/src/ds/molecules/Chat/index.ts | 3 + .../molecules/DocumentTextEditor/actions.ts | 114 +++++++++ .../molecules/DocumentTextEditor/editor.tsx | 135 ++++++++--- .../ds/molecules/DocumentTextEditor/index.tsx | 1 + packages/web-ui/src/ds/molecules/index.ts | 1 + packages/web-ui/src/ds/tokens/colors.ts | 1 + packages/web-ui/src/lib/commonTypes.ts | 3 +- .../web-ui/src/lib/hooks/useAutoScroll.ts | 54 +++++ .../web-ui/src/lib/hooks/useLocalStorage.ts | 109 +++++++++ .../src/sections/Document/Editor/Header.tsx | 18 ++ .../Document/Editor/Playground/Chat.tsx | 221 ++++++++++++++++++ .../Document/Editor/Playground/Preview.tsx | 87 +++++++ .../Document/Editor/Playground/index.tsx | 106 +++++++++ .../src/sections/Document/Editor/index.tsx | 99 ++++---- .../Sidebar/Files/NodeHeaderWrapper/index.tsx | 8 + pnpm-lock.yaml | 57 ++++- 33 files changed, 1260 insertions(+), 185 deletions(-) create mode 100644 packages/web-ui/src/ds/molecules/Chat/ChatTextArea/index.tsx create mode 100644 packages/web-ui/src/ds/molecules/Chat/ErrorMessage/index.tsx create mode 100644 packages/web-ui/src/ds/molecules/Chat/Message/index.tsx create mode 100644 packages/web-ui/src/ds/molecules/Chat/MessageList/index.tsx create mode 100644 packages/web-ui/src/ds/molecules/Chat/index.ts create mode 100644 packages/web-ui/src/ds/molecules/DocumentTextEditor/actions.ts create mode 100644 packages/web-ui/src/lib/hooks/useAutoScroll.ts create mode 100644 packages/web-ui/src/lib/hooks/useLocalStorage.ts create mode 100644 packages/web-ui/src/sections/Document/Editor/Header.tsx create mode 100644 packages/web-ui/src/sections/Document/Editor/Playground/Chat.tsx create mode 100644 packages/web-ui/src/sections/Document/Editor/Playground/Preview.tsx create mode 100644 packages/web-ui/src/sections/Document/Editor/Playground/index.tsx diff --git a/packages/compiler/src/compiler/chain.test.ts b/packages/compiler/src/compiler/chain.test.ts index 1aba5189c..267ab7de4 100644 --- a/packages/compiler/src/compiler/chain.test.ts +++ b/packages/compiler/src/compiler/chain.test.ts @@ -1,11 +1,6 @@ import { CHAIN_STEP_TAG } from '$compiler/constants' import CompileError from '$compiler/error/error' -import { - AssistantMessage, - ContentType, - Conversation, - MessageRole, -} from '$compiler/types' +import { Conversation, MessageRole } from '$compiler/types' import { describe, expect, it, vi } from 'vitest' import { Chain } from './chain' @@ -24,19 +19,8 @@ const getExpectedError = async ( throw new Error('Expected an error to be thrown') } -const assistantMessage = (content?: string): AssistantMessage => ({ - role: MessageRole.assistant, - content: [ - { - type: ContentType.text, - value: content ?? '', - }, - ], - toolCalls: [], -}) - -async function defaultCallback(): Promise { - return assistantMessage('') +async function defaultCallback(): Promise { + return '' } async function complete({ @@ -45,11 +29,11 @@ async function complete({ maxSteps = 50, }: { chain: Chain - callback?: (convo: Conversation) => Promise + callback?: (convo: Conversation) => Promise maxSteps?: number }): Promise { let steps = 0 - let response: AssistantMessage | undefined = undefined + let response: string | undefined = undefined while (true) { const { completed, conversation } = await chain.step(response) if (completed) return conversation @@ -152,7 +136,7 @@ describe('chain', async () => { expect(conversation1.messages[0]!.content[0]!.value).toBe('Message 1') const { completed: completed2, conversation: conversation2 } = - await chain.step(assistantMessage('response')) + await chain.step('response') expect(completed2).toBe(true) expect(conversation2.messages.length).toBe(3) @@ -191,7 +175,7 @@ describe('chain', async () => { parameters: {}, }) - const action = () => chain.step(assistantMessage()) + const action = () => chain.step('') const error = await getExpectedError(action, Error) expect(error.message).toBe( 'A response is not allowed before the chain has started', @@ -213,7 +197,7 @@ describe('chain', async () => { let { completed: stop } = await chain.step() while (!stop) { - const { completed } = await chain.step(assistantMessage()) + const { completed } = await chain.step('') stop = completed } @@ -432,18 +416,8 @@ describe('chain', async () => { parameters: {}, }) - const response = { - role: MessageRole.assistant, - content: [ - { - type: ContentType.text, - value: 'foo', - }, - ], - } as AssistantMessage - await chain.step() - const { conversation } = await chain.step(response) + const { conversation } = await chain.step('foo') expect(conversation.messages.length).toBe(2) expect(conversation.messages[0]!.content[0]!.value).toBe('foo') @@ -470,16 +444,15 @@ describe('chain', async () => { expect(step1.config.model).toBe('foo-1') expect(step1.config.temperature).toBe(0.5) - const { conversation: step2 } = await chain.step(assistantMessage()) + const { conversation: step2 } = await chain.step('') expect(step2.config.model).toBe('foo-2') expect(step2.config.temperature).toBe(0.5) - const { conversation: step3 } = await chain.step(assistantMessage()) + const { conversation: step3 } = await chain.step('') expect(step3.config.model).toBe('foo-1') expect(step3.config.temperature).toBe('1') - const { conversation: finalConversation } = - await chain.step(assistantMessage()) + const { conversation: finalConversation } = await chain.step('') expect(finalConversation.config.model).toBe('foo-1') expect(finalConversation.config.temperature).toBe(0.5) }) diff --git a/packages/compiler/src/compiler/chain.ts b/packages/compiler/src/compiler/chain.ts index ff2cc9f74..8f864e10c 100644 --- a/packages/compiler/src/compiler/chain.ts +++ b/packages/compiler/src/compiler/chain.ts @@ -1,11 +1,6 @@ import parse from '$compiler/parser' import { Fragment } from '$compiler/parser/interfaces' -import { - AssistantMessage, - Config, - Conversation, - Message, -} from '$compiler/types' +import { Config, Conversation, Message } from '$compiler/types' import { Compile } from './compile' import Scope from './scope' @@ -20,7 +15,7 @@ export class Chain { private ast: Fragment private scope: Scope private didStart: boolean = false - private completed: boolean = false + private _completed: boolean = false private messages: Message[] = [] private config: Config | undefined @@ -37,8 +32,8 @@ export class Chain { this.scope = new Scope(parameters) } - async step(response?: AssistantMessage): Promise { - if (this.completed) { + async step(response?: string): Promise { + if (this._completed) { throw new Error('The chain has already completed') } if (!this.didStart && response !== undefined) { @@ -63,7 +58,7 @@ export class Chain { this.ast = ast this.messages.push(...messages) this.config = globalConfig ?? this.config - this.completed = completed || this.completed + this._completed = completed || this._completed const config = { ...this.config, @@ -75,7 +70,11 @@ export class Chain { messages: this.messages, config, }, - completed: this.completed, + completed: this._completed, } } + + get completed(): boolean { + return this._completed + } } diff --git a/packages/compiler/src/compiler/compile.ts b/packages/compiler/src/compiler/compile.ts index 15d4276c0..d9667e74d 100644 --- a/packages/compiler/src/compiler/compile.ts +++ b/packages/compiler/src/compiler/compile.ts @@ -56,7 +56,7 @@ export class Compile { private messages: Message[] = [] private config: Config | undefined - private stepResponse: AssistantMessage | undefined + private stepResponse: string | undefined private accumulatedText: string = '' private accumulatedContent: MessageContent[] = [] @@ -71,7 +71,7 @@ export class Compile { rawText: string globalScope: Scope ast: Fragment - stepResponse?: AssistantMessage + stepResponse?: string }) { this.rawText = rawText this.globalScope = globalScope @@ -186,7 +186,16 @@ export class Compile { } private popStepResponse(): AssistantMessage | undefined { - const response = this.stepResponse + if (this.stepResponse === undefined) return undefined + const response = { + role: MessageRole.assistant, + content: [ + { + type: ContentType.text, + value: this.stepResponse, + }, + ], + } as AssistantMessage this.stepResponse = undefined return response } diff --git a/packages/compiler/src/compiler/index.ts b/packages/compiler/src/compiler/index.ts index 0c81846d7..a83221a86 100644 --- a/packages/compiler/src/compiler/index.ts +++ b/packages/compiler/src/compiler/index.ts @@ -19,6 +19,16 @@ export async function render({ return conversation } +export function createChain({ + prompt, + parameters, +}: { + prompt: string + parameters: Record +}): Chain { + return new Chain({ prompt, parameters }) +} + export function readMetadata({ prompt, referenceFn, @@ -28,3 +38,5 @@ export function readMetadata({ }): Promise { return new ReadMetadata({ prompt, referenceFn }).run() } + +export { Chain } diff --git a/packages/compiler/src/compiler/readMetadata.test.ts b/packages/compiler/src/compiler/readMetadata.test.ts index 280cc64ae..44fb8fff3 100644 --- a/packages/compiler/src/compiler/readMetadata.test.ts +++ b/packages/compiler/src/compiler/readMetadata.test.ts @@ -305,6 +305,21 @@ describe('parameters', async () => { expect(metadata.parameters).toEqual(new Set()) }) + + it('adds the correct parameters to the scope context', async () => { + const prompt = ` + ${CUSTOM_TAG_START}foo${CUSTOM_TAG_END} + ${CUSTOM_TAG_START}bar${CUSTOM_TAG_END} + ${CUSTOM_TAG_START}#each arr as val${CUSTOM_TAG_END} + ${CUSTOM_TAG_START}/each${CUSTOM_TAG_END} + ` + + const metadata = await readMetadata({ + prompt: removeCommonIndent(prompt), + }) + + expect(metadata.parameters).toEqual(new Set(['foo', 'bar', 'arr'])) + }) }) describe('referenced prompts', async () => { diff --git a/packages/compiler/src/compiler/readMetadata.ts b/packages/compiler/src/compiler/readMetadata.ts index 3c2312063..9a64f23e0 100644 --- a/packages/compiler/src/compiler/readMetadata.ts +++ b/packages/compiler/src/compiler/readMetadata.ts @@ -23,7 +23,13 @@ import { ReferencePromptFn } from './compile' import { readConfig } from './config' import { updateScopeContextForNode } from './logic' import { ScopeContext } from './scope' -import { isContentTag, isMessageTag, isRefTag, isToolCallTag } from './utils' +import { + isChainStepTag, + isContentTag, + isMessageTag, + isRefTag, + isToolCallTag, +} from './utils' function copyScopeContext(scopeContext: ScopeContext): ScopeContext { return { @@ -223,6 +229,8 @@ export class ReadMetadata { } if (node.type === 'EachBlock') { + await this.updateScopeContext({ node: node.expression, scopeContext }) + const elseScope = copyScopeContext(scopeContext) for await (const childNode of node.else?.children ?? []) { await this.readBaseMetadata({ @@ -442,6 +450,25 @@ export class ReadMetadata { return } + if (isChainStepTag(node)) { + const attributes = await this.listTagAttributes({ + tagNode: node, + scopeContext, + literalAttributes: ['as'], + }) + + if (attributes.has('as')) { + const asAttribute = node.attributes.find((a) => a.name === 'as')! + const asValue = (asAttribute.value as TemplateNode[]) + .map((n) => n.value) + .join('') + + scopeContext.definedVariables.add(asValue) + } + + return + } + this.baseNodeError(errors.unknownTag(node.name), node) return } diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 5b0000101..62b54ca24 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -45,6 +45,7 @@ "react": "18.3.0", "react-dom": "18.3.0", "react-resizable-panels": "^2.0.22", + "react-textarea-autosize": "^8.5.3", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "use-debounce": "^10.0.1", @@ -52,6 +53,7 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@faker-js/faker": "^8.4.1", "@latitude-data/eslint-config": "workspace:*", "@latitude-data/typescript-config": "workspace:*", "@testing-library/dom": "^10.3.2", diff --git a/packages/web-ui/src/ds/atoms/Badge/index.tsx b/packages/web-ui/src/ds/atoms/Badge/index.tsx index 684fb1857..e1ff99475 100644 --- a/packages/web-ui/src/ds/atoms/Badge/index.tsx +++ b/packages/web-ui/src/ds/atoms/Badge/index.tsx @@ -16,6 +16,8 @@ const badgeVariants = cva( 'border-transparent bg-accent text-accent-foreground hover:bg-accent/80', destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + muted: + 'border-transparent bg-muted text-muted-foreground hover:bg-muted/80', outline: 'text-foreground', }, }, diff --git a/packages/web-ui/src/ds/atoms/Button/index.tsx b/packages/web-ui/src/ds/atoms/Button/index.tsx index 5b24562c6..11cc8fcbf 100644 --- a/packages/web-ui/src/ds/atoms/Button/index.tsx +++ b/packages/web-ui/src/ds/atoms/Button/index.tsx @@ -2,8 +2,7 @@ import { ButtonHTMLAttributes, forwardRef, ReactNode } from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { Slot, Slottable } from '@radix-ui/react-slot' -import { IconProps, Icons } from '$ui/ds/atoms/Icons' -import { colors } from '$ui/ds/tokens' +import { Icon, IconProps } from '$ui/ds/atoms/Icons' import { cn } from '$ui/lib/utils' const buttonVariants = cva( @@ -43,10 +42,7 @@ const buttonVariants = cva( export type ButtonProps = ButtonHTMLAttributes & VariantProps & { children: ReactNode - icon?: { - name: keyof typeof Icons - props?: IconProps - } + iconProps?: IconProps fullWidth?: boolean asChild?: boolean isLoading?: boolean @@ -57,7 +53,7 @@ const Button = forwardRef(function Button( className, variant, size, - icon, + iconProps, fullWidth = false, asChild = false, isLoading, @@ -67,8 +63,6 @@ const Button = forwardRef(function Button( ref, ) { const Comp = asChild ? Slot : 'button' - const ButtonIcon = icon ? Icons[icon.name] : null - const iconProps = icon?.props ?? {} return ( (function Button( >
- {ButtonIcon ? ( - - ) : null} + {iconProps ? : null} {children}
diff --git a/packages/web-ui/src/ds/atoms/DropdownMenu/index.tsx b/packages/web-ui/src/ds/atoms/DropdownMenu/index.tsx index 6310ea23b..06f20db02 100644 --- a/packages/web-ui/src/ds/atoms/DropdownMenu/index.tsx +++ b/packages/web-ui/src/ds/atoms/DropdownMenu/index.tsx @@ -1,6 +1,8 @@ import { ReactNode, useCallback, useState } from 'react' +import { Check } from 'lucide-react' import { Button, type ButtonProps } from '$ui/ds/atoms/Button' +import { Icon, type IconProps } from '$ui/ds/atoms/Icons' import Text from '$ui/ds/atoms/Text' import { @@ -20,20 +22,21 @@ export type TriggerButtonProps = Omit & { } export const TriggerButton = ({ label, - variant = 'ghost', - icon = { name: 'ellipsisVertical', props: { color: 'foregroundMuted' } }, + variant = 'outline', + iconProps = { name: 'ellipsis', color: 'foregroundMuted' }, + className = 'w-8 px-1', ...buttonProps }: TriggerButtonProps) => { return ( ) @@ -43,29 +46,42 @@ export type MenuOption = { label: string onClick: () => void type?: 'normal' | 'destructive' - icon?: ReactNode + iconProps?: IconProps disabled?: boolean shortcut?: string + checked?: boolean | undefined } function DropdownItem({ - icon, + iconProps, onClick, type = 'normal', label, shortcut, disabled, + checked, }: MenuOption) { const onSelect = useCallback(() => { if (disabled) return onClick() }, [disabled, onClick]) return ( - - {icon} - - {label} - + + {iconProps ? : null} +
+ + {label} + +
{shortcut && {shortcut}} + {checked !== undefined && ( +
+ {checked ? : null} +
+ )}
) } diff --git a/packages/web-ui/src/ds/atoms/FormField/index.tsx b/packages/web-ui/src/ds/atoms/FormField/index.tsx index 3140b5a26..070494c0b 100644 --- a/packages/web-ui/src/ds/atoms/FormField/index.tsx +++ b/packages/web-ui/src/ds/atoms/FormField/index.tsx @@ -104,6 +104,7 @@ function FormField({ + ) } diff --git a/packages/web-ui/src/ds/atoms/Text/index.tsx b/packages/web-ui/src/ds/atoms/Text/index.tsx index 72a4f9a3f..aab13797d 100644 --- a/packages/web-ui/src/ds/atoms/Text/index.tsx +++ b/packages/web-ui/src/ds/atoms/Text/index.tsx @@ -275,7 +275,7 @@ namespace Text { children, color = 'foreground', overflow = 'auto', - whiteSpace = 'normal', + whiteSpace = 'pre', underline = false, lineThrough = false, size = 'h6', diff --git a/packages/web-ui/src/ds/molecules/Chat/ChatTextArea/index.tsx b/packages/web-ui/src/ds/molecules/Chat/ChatTextArea/index.tsx new file mode 100644 index 000000000..9c90bb5a7 --- /dev/null +++ b/packages/web-ui/src/ds/molecules/Chat/ChatTextArea/index.tsx @@ -0,0 +1,55 @@ +'use client' + +import { KeyboardEvent, useCallback, useState } from 'react' + +import { Button } from '$ui/ds/atoms' +import TextareaAutosize from 'react-textarea-autosize' + +export function ChatTextArea({ + placeholder, + disabled = false, + onSubmit, +}: { + placeholder: string + disabled?: boolean + onSubmit?: (value: string) => void +}) { + const [value, setValue] = useState('') + + const handleSubmit = useCallback(() => { + if (disabled) return + if (value === '') return + setValue('') + onSubmit?.(value) + }, [value, onSubmit, disabled]) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit() + } + }, + [handleSubmit], + ) + + return ( +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + minRows={1} + maxRows={5} + /> +
+ +
+
+ ) +} diff --git a/packages/web-ui/src/ds/molecules/Chat/ErrorMessage/index.tsx b/packages/web-ui/src/ds/molecules/Chat/ErrorMessage/index.tsx new file mode 100644 index 000000000..cf3352d86 --- /dev/null +++ b/packages/web-ui/src/ds/molecules/Chat/ErrorMessage/index.tsx @@ -0,0 +1,29 @@ +import { CompileError, ContentType } from '@latitude-data/compiler' +import { Text } from '$ui/ds/atoms' + +import { Message } from '../Message' + +export function ErrorMessage({ error }: { error: Error }) { + return ( +
+ + {error instanceof CompileError && ( +
+ {error + .toString() + .split('\n') + .map((line, index) => ( +
+ {line} +
+ ))} +
+ )} +
+ ) +} diff --git a/packages/web-ui/src/ds/molecules/Chat/Message/index.tsx b/packages/web-ui/src/ds/molecules/Chat/Message/index.tsx new file mode 100644 index 000000000..681df488e --- /dev/null +++ b/packages/web-ui/src/ds/molecules/Chat/Message/index.tsx @@ -0,0 +1,48 @@ +import { MessageContent } from '@latitude-data/compiler' +import { Badge, BadgeProps, Text } from '$ui/ds/atoms' +import { TextColor } from '$ui/ds/tokens' +import { cn } from '$ui/lib/utils' + +export type MessageProps = { + role: string + content: MessageContent[] + className?: string + badgeVariant?: BadgeProps['variant'] + textColor?: TextColor + animatePulse?: boolean +} + +export function Message({ + role, + content, + animatePulse, + badgeVariant = 'muted', + textColor = 'foregroundMuted', +}: MessageProps) { + return ( +
+
+ + {role.charAt(0).toUpperCase() + role.slice(1)} + +
+
+ {content.map((c, contentIndex) => + c.value.split('\n').map((line, lineIndex) => ( + + {line} + + )), + )} +
+
+ ) +} diff --git a/packages/web-ui/src/ds/molecules/Chat/MessageList/index.tsx b/packages/web-ui/src/ds/molecules/Chat/MessageList/index.tsx new file mode 100644 index 000000000..b7558fa9f --- /dev/null +++ b/packages/web-ui/src/ds/molecules/Chat/MessageList/index.tsx @@ -0,0 +1,23 @@ +import { Message as ConversationMessage } from '@latitude-data/compiler' + +import { Message, MessageProps } from '../Message' + +export function MessageList({ + messages, + badgeVariant, + textColor, +}: { + messages: ConversationMessage[] + badgeVariant?: MessageProps['badgeVariant'] + textColor?: MessageProps['textColor'] +}) { + return messages.map((message, index) => ( + + )) +} diff --git a/packages/web-ui/src/ds/molecules/Chat/index.ts b/packages/web-ui/src/ds/molecules/Chat/index.ts new file mode 100644 index 000000000..2cebc1446 --- /dev/null +++ b/packages/web-ui/src/ds/molecules/Chat/index.ts @@ -0,0 +1,3 @@ +export * from './Message' +export * from './ErrorMessage' +export * from './MessageList' diff --git a/packages/web-ui/src/ds/molecules/DocumentTextEditor/actions.ts b/packages/web-ui/src/ds/molecules/DocumentTextEditor/actions.ts new file mode 100644 index 000000000..d9d5149d5 --- /dev/null +++ b/packages/web-ui/src/ds/molecules/DocumentTextEditor/actions.ts @@ -0,0 +1,114 @@ +import { Monaco } from '@monaco-editor/react' +import { editor } from 'monaco-editor' + +export function registerActions( + editor: editor.IStandaloneCodeEditor, + monaco: Monaco, +) { + editor.addAction({ + id: 'toggleBlockComment', + label: 'Toggle comment', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], + run: () => { + const selection = editor.getSelection() + if (!selection) return + + const model = editor.getModel() + if (!model) return + + const startLineNumber = selection.startLineNumber + const endLineNumber = selection.endLineNumber + const startColumn = selection.startColumn + const endColumn = selection.endColumn + + if (startLineNumber === endLineNumber && startColumn === endColumn) { + // Single line, no selection + const lineContent = model.getLineContent(startLineNumber) + const startComment = '/* ' + const endComment = ' */' + + if ( + lineContent.trim().startsWith(startComment) && + lineContent.trim().endsWith(endComment) + ) { + // Uncomment the line + const uncommentedLine = lineContent + .trim() + .slice(startComment.length, -endComment.length) + model.pushEditOperations( + [], + [ + { + range: new monaco.Range( + startLineNumber, + 1, + startLineNumber, + lineContent.length + 1, + ), + text: uncommentedLine.trim(), + }, + ], + () => null, + ) + } else { + // Comment the line + const commentedLine = `${startComment}${lineContent}${endComment}` + model.pushEditOperations( + [], + [ + { + range: new monaco.Range( + startLineNumber, + 1, + startLineNumber, + lineContent.length + 1, + ), + text: commentedLine, + }, + ], + () => null, + ) + } + } else { + // Multiple lines or selection + const selectedText = model.getValueInRange(selection) + const startComment = '/* ' + const endComment = ' */' + + if ( + selectedText.startsWith(startComment) && + selectedText.endsWith(endComment) + ) { + // Uncomment the selection + const uncommented = selectedText.slice( + startComment.length, + -endComment.length, + ) + model.pushEditOperations( + [], + [ + { + range: selection, + text: uncommented, + }, + ], + () => null, + ) + } else { + // Comment the selection + const commented = `${startComment}${selectedText}${endComment}` + model.pushEditOperations( + [], + [ + { + range: selection, + text: commented, + }, + ], + () => null, + ) + } + } + }, + }) +} diff --git a/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx b/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx index da339a34e..7975d68ee 100644 --- a/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx +++ b/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx @@ -1,12 +1,16 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' +import { AlertCircle, CheckCircle2, LoaderCircle } from 'lucide-react' import { CompileError } from '@latitude-data/compiler' import Editor, { Monaco } from '@monaco-editor/react' +import { Button, Text } from '$ui/ds/atoms' +import { AppLocalStorage, useLocalStorage } from '$ui/lib/hooks/useLocalStorage' import { MarkerSeverity, type editor } from 'monaco-editor' import type { DocumentTextEditorProps } from '.' +import { registerActions } from './actions' import { themeRules, tokenizer } from './language' export type DocumentError = { @@ -22,17 +26,42 @@ export function DocumentTextEditor({ metadata, onChange, readOnlyMessage, + isSaved, }: DocumentTextEditorProps) { const [defaultValue, _] = useState(value) const editorRef = useRef(null) const monacoRef = useRef(null) const [isEditorMounted, setIsEditorMounted] = useState(false) // to avoid race conditions + const [editorLines, setEditorLines] = useState(value.split('\n').length) + const { value: showLineNumbers } = useLocalStorage({ + key: AppLocalStorage.editorLineNumbers, + defaultValue: true, + }) + const { value: wrapText } = useLocalStorage({ + key: AppLocalStorage.editorWrapText, + defaultValue: true, + }) + const { value: showMinimap } = useLocalStorage({ + key: AppLocalStorage.editorMinimap, + defaultValue: false, + }) + + const focusNextError = useCallback(() => { + if (!editorRef.current) return + const editor = editorRef.current + editor.trigger('anystring', 'editor.action.marker.next', '') + }, []) const handleEditorWillMount = useCallback((monaco: Monaco) => { const style = getComputedStyle(document.body) monaco.languages.register({ id: 'document' }) monaco.languages.setMonarchTokensProvider('document', { tokenizer }) + monaco.languages.setLanguageConfiguration('document', { + comments: { + blockComment: ['/*', '*/'], + }, + }) monaco.editor.defineTheme('latitude', { base: 'vs', inherit: true, @@ -48,6 +77,8 @@ export function DocumentTextEditor({ editorRef.current = editor monacoRef.current = monaco setIsEditorMounted(true) + + registerActions(editor, monaco) }, [], ) @@ -81,46 +112,82 @@ export function DocumentTextEditor({ const handleValueChange = useCallback( (value: string | undefined) => { + setEditorLines(value?.split('\n').length ?? 0) onChange?.(value ?? '') }, [onChange], ) return ( -
-
- +
+
+
+ +
+
+
+
+ {editorLines} lines +
+
+ {(metadata?.errors.length ?? 0) > 0 && ( + + )} + +
+ {isSaved ? ( + <> + Saved + + + ) : ( + <> + Saving... + + + )} +
+
) diff --git a/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx b/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx index c314233fe..0e87418c4 100644 --- a/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx +++ b/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx @@ -11,6 +11,7 @@ export type DocumentTextEditorProps = { metadata?: ConversationMetadata onChange?: (value: string) => void readOnlyMessage?: string + isSaved: boolean } const DocumentTextEditor = lazy(() => diff --git a/packages/web-ui/src/ds/molecules/index.ts b/packages/web-ui/src/ds/molecules/index.ts index a32a2daea..6d840d34d 100644 --- a/packages/web-ui/src/ds/molecules/index.ts +++ b/packages/web-ui/src/ds/molecules/index.ts @@ -1,2 +1,3 @@ export { default as FocusHeader } from './FocusHeader' export * from './DocumentTextEditor' +export * from './Chat' diff --git a/packages/web-ui/src/ds/tokens/colors.ts b/packages/web-ui/src/ds/tokens/colors.ts index 6910002f9..5b1bfdea8 100644 --- a/packages/web-ui/src/ds/tokens/colors.ts +++ b/packages/web-ui/src/ds/tokens/colors.ts @@ -10,6 +10,7 @@ export const colors = { destructive: 'text-destructive', destructiveForeground: 'text-destructive-foreground', accentForeground: 'text-accent-foreground', + secondaryForeground: 'text-secondary-foreground', }, borderColors: { transparent: 'border-transparent', diff --git a/packages/web-ui/src/lib/commonTypes.ts b/packages/web-ui/src/lib/commonTypes.ts index 85cfc059d..58f75c762 100644 --- a/packages/web-ui/src/lib/commonTypes.ts +++ b/packages/web-ui/src/lib/commonTypes.ts @@ -1,3 +1,4 @@ -import type { Dispatch, SetStateAction } from 'react' +import type { Dispatch } from 'react' export type ReactStateDispatch = Dispatch> +export type SetStateAction = T | ((prevState: T) => T) diff --git a/packages/web-ui/src/lib/hooks/useAutoScroll.ts b/packages/web-ui/src/lib/hooks/useAutoScroll.ts new file mode 100644 index 000000000..3294e9332 --- /dev/null +++ b/packages/web-ui/src/lib/hooks/useAutoScroll.ts @@ -0,0 +1,54 @@ +import { RefObject, useEffect } from 'react' + +export function useAutoScroll( + ref: RefObject, + options?: { + startAtBottom?: boolean + onScrollChange?: (isScrolledToBottom: boolean) => void + }, +) { + const { startAtBottom = false } = options || {} + + useEffect(() => { + const container = ref.current + if (!container) return + + if (startAtBottom) container.scrollTop = container.scrollHeight + + let isScrolledToBottom = false + + const scrollHandler = () => { + const newIsScrolledToBottom = + container.scrollHeight - container.clientHeight <= + container.scrollTop + 1 + + if (newIsScrolledToBottom !== isScrolledToBottom) { + isScrolledToBottom = newIsScrolledToBottom + options?.onScrollChange?.(isScrolledToBottom) + } + } + + const resizeHandler = () => { + if (isScrolledToBottom) { + setTimeout(() => { + container.scrollTop = container.scrollHeight + }, 0) + } + } + + const resizeObserver = new ResizeObserver(resizeHandler) + resizeObserver.observe(container) + + const mutationObserver = new MutationObserver(resizeHandler) + mutationObserver.observe(container, { childList: true, subtree: true }) + + container.addEventListener('scroll', scrollHandler) + scrollHandler() + + return () => { + resizeObserver.disconnect() + mutationObserver.disconnect() + container.removeEventListener('scroll', scrollHandler) + } + }, []) +} diff --git a/packages/web-ui/src/lib/hooks/useLocalStorage.ts b/packages/web-ui/src/lib/hooks/useLocalStorage.ts new file mode 100644 index 000000000..51322c9f1 --- /dev/null +++ b/packages/web-ui/src/lib/hooks/useLocalStorage.ts @@ -0,0 +1,109 @@ +'use client' + +import { useCallback } from 'react' + +import { ReactStateDispatch, SetStateAction } from '$ui/lib/commonTypes' +import { create, StateCreator } from 'zustand' +import { persist, PersistOptions } from 'zustand/middleware' + +export enum AppLocalStorage { + editorLineNumbers = 'editorLineNumbers', + editorWrapText = 'editorWrapText', + editorMinimap = 'editorMinimap', +} + +const isLocalStorageAvailable = (() => { + try { + const testKey = '__test__' + localStorage.setItem(testKey, testKey) + localStorage.removeItem(testKey) + return true + } catch (e) { + return false + } +})() + +export function buildKey(key: AppLocalStorage): string { + return `latitude:${key}` +} + +export function getStorageValue(key: string, defaultValue: unknown) { + if (!isLocalStorageAvailable) return defaultValue + + const saved = localStorage.getItem(key) + if (saved == 'undefined') return undefined + return saved ? JSON.parse(saved) : defaultValue +} + +type LocalStorageStore = { + values: Partial> + setValue: (key: string, value: unknown) => void +} + +type LocalStorageStorePersist = ( + config: StateCreator, + options: PersistOptions, +) => StateCreator + +const useLocalStorageStore = create( + (persist as LocalStorageStorePersist)( + (set) => ({ + values: {}, + setValue: (key, value) => { + if (typeof value === 'function') { + value = value(getStorageValue(key, null)) + } + + if (isLocalStorageAvailable) { + localStorage.setItem(key, JSON.stringify(value)) + } + + set((state) => ({ + values: { + ...state.values, + [key]: value, + }, + })) + }, + }), + { + name: 'local-storage-store', // name of the item in the storage + getStorage: () => localStorage, // (optional) by default, 'localStorage' is used + }, + ), +) + +type Props = { + key: AppLocalStorage + defaultValue: T +} +type ReturnType = { + value: T + setValue: ReactStateDispatch +} +export const useLocalStorage = ({ + key, + defaultValue, +}: Props): ReturnType => { + const fullKey = buildKey(key) + const { value, setValue } = useLocalStorageStore((state) => { + if (!(fullKey in state.values)) { + // Initialize + state.values[fullKey] = getStorageValue(fullKey, defaultValue) + } + return { + value: state.values[fullKey] as T, + setValue: state.setValue, + } + }) + + return { + value, + setValue: useCallback( + (newValue: SetStateAction) => { + setValue(fullKey, newValue) + }, + [setValue, fullKey], + ), + } +} diff --git a/packages/web-ui/src/sections/Document/Editor/Header.tsx b/packages/web-ui/src/sections/Document/Editor/Header.tsx new file mode 100644 index 000000000..c2eb20ad9 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Editor/Header.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react' + +import { Text } from '$ui/ds/atoms' + +export function Header({ + title, + children, +}: { + title: string + children?: ReactNode +}) { + return ( +
+ {title} +
{children}
+
+ ) +} diff --git a/packages/web-ui/src/sections/Document/Editor/Playground/Chat.tsx b/packages/web-ui/src/sections/Document/Editor/Playground/Chat.tsx new file mode 100644 index 000000000..3ab5d8262 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Editor/Playground/Chat.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { faker } from '@faker-js/faker' +import { + Chain, + CompileError, + ContentType, + Conversation, + Message as ConversationMessage, + ConversationMetadata, + MessageRole, +} from '@latitude-data/compiler' +import { Text } from '$ui/ds/atoms' +import { ErrorMessage, Message, MessageList } from '$ui/ds/molecules' +import { ChatTextArea } from '$ui/ds/molecules/Chat/ChatTextArea' +import { useAutoScroll } from '$ui/lib/hooks/useAutoScroll' +import { cn } from '$ui/lib/utils' + +async function* mockAIResponse( + response: string, + delay = 25, +): AsyncGenerator<[string, number]> { + for (let i = 0; i < response.length; i++) { + const minDelay = Math.max(0, delay * 0.5) + const maxDelay = Math.max(0, delay * 2) + const delayMs = Math.floor(Math.random() * (maxDelay - minDelay) + minDelay) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + yield [response[i]!, 1] + } +} + +export default function Chat({ + metadata, + parameters, +}: { + metadata: ConversationMetadata + parameters: Record +}) { + const [initialMetadata] = useState(metadata) + const [error, setError] = useState() + const [tokens, setTokens] = useState(0) + const [isScrolledToBottom, setIsScrolledToBottom] = useState(false) + const containerRef = useRef(null) + useAutoScroll(containerRef, { + startAtBottom: true, + onScrollChange: setIsScrolledToBottom, + }) + + const [chain] = useState( + new Chain({ + prompt: initialMetadata.resolvedPrompt, + parameters, + }), + ) + const runChainOnce = useRef(false) + const [chainLength, setChainLength] = useState(Infinity) // Index where the chain ends and the chat begins + + const [conversation, setConversation] = useState() + + const [responseStream, setResponseStream] = useState() + const StreamMessage = useMemo(() => { + if (responseStream === undefined) return null + if (conversation === undefined) return null + if (conversation.messages.length < chainLength - 1) { + return ( + + ) + } + if (conversation.messages.length === chainLength - 1) { + return ( + + ) + } + return ( + + ) + }, [responseStream, conversation, chainLength]) + + const addMessage = useCallback((message: ConversationMessage) => { + let newConversation: Conversation + setConversation((prevConversation) => { + newConversation = { + ...prevConversation, + messages: [...(prevConversation?.messages ?? []), message], + } as Conversation + return newConversation + }) + return newConversation! + }, []) + + const generateResponse = useCallback(async (_: Conversation) => { + const mockResponse = faker.hacker.phrase() + let response = '' + setResponseStream(response) + + for await (const [char, tokenCount] of mockAIResponse(mockResponse)) { + response += char + setResponseStream(response) + setTokens((prev) => prev + tokenCount) + } + + setResponseStream(undefined) + addMessage({ + role: MessageRole.assistant, + content: [ + { + type: ContentType.text, + value: response, + }, + ], + toolCalls: [], + }) + return response + }, []) + + const runChain = useCallback((lastResponse?: string) => { + chain + .step(lastResponse) + .then(async ({ completed, conversation }) => { + if (completed) setChainLength(conversation.messages.length + 1) + setConversation(conversation) + + const response = await generateResponse(conversation) + + if (completed) return + runChain(response) + }) + .catch((error) => { + setError(error) + if (error instanceof CompileError) { + console.error(error.toString()) + } else { + console.log(error) + } + }) + }, []) + + useEffect(() => { + if (runChainOnce.current) return + runChainOnce.current = true // Prevent double-running when StrictMode is enabled + runChain() + }, []) + + const submitUserMessage = useCallback((input: string) => { + const newConversation = addMessage({ + role: MessageRole.user, + content: [ + { + type: ContentType.text, + value: input, + }, + ], + }) + + generateResponse(newConversation) + }, []) + + return ( +
+
+ Prompt + + {(conversation?.messages.length ?? 0) >= chainLength && ( + + )} + {(conversation?.messages.length ?? 0) > chainLength && ( + <> + Chat + + + )} + {StreamMessage} + {error !== undefined && } +
+
+
+ {tokens} tokens +
+ +
+
+ ) +} diff --git a/packages/web-ui/src/sections/Document/Editor/Playground/Preview.tsx b/packages/web-ui/src/sections/Document/Editor/Playground/Preview.tsx new file mode 100644 index 000000000..f3b59bef4 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Editor/Playground/Preview.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from 'react' + +import { + Chain, + CompileError, + Conversation, + ConversationMetadata, +} from '@latitude-data/compiler' +import { Button, Text, Tooltip } from '$ui/ds/atoms' +import { ErrorMessage, Message } from '$ui/ds/molecules' +import { useAutoScroll } from '$ui/lib/hooks/useAutoScroll' + +export default function Preview({ + metadata, + parameters, + runPrompt, +}: { + metadata: ConversationMetadata | undefined + parameters: Record + runPrompt: () => void +}) { + const [conversation, setConversation] = useState( + undefined, + ) + const [completed, setCompleted] = useState(true) + const [error, setError] = useState(undefined) + const containerRef = useRef(null) + useAutoScroll(containerRef, { startAtBottom: true }) + + useEffect(() => { + if (!metadata) return + if (metadata.errors.length > 0) return + + const chain = new Chain({ + prompt: metadata.resolvedPrompt, + parameters, + }) + + chain + .step() + .then(({ conversation, completed }) => { + setError(undefined) + setConversation(conversation) + setCompleted(completed) + }) + .catch((error) => { + setConversation(undefined) + setCompleted(true) + setError(error) + if (error instanceof CompileError) { + console.error(error.toString()) + } else { + console.log(error) + } + }) + }, [metadata, parameters]) + + return ( +
+ Preview + {(conversation?.messages ?? []).map((message, index) => ( + + ))} + {error !== undefined && } + {!completed && ( +
+ + Showing the first step. Other steps will show after running. + +
+ )} + +
+ {error || (metadata?.errors.length ?? 0) > 0 ? ( + Run prompt}> + There are errors in your prompt. Please fix them before running. + + ) : ( + + )} +
+
+ ) +} diff --git a/packages/web-ui/src/sections/Document/Editor/Playground/index.tsx b/packages/web-ui/src/sections/Document/Editor/Playground/index.tsx new file mode 100644 index 000000000..9919811d7 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Editor/Playground/index.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { ConversationMetadata } from '@latitude-data/compiler' +import { Badge, Button, Input, Text } from '$ui/ds/atoms' + +import { Header } from '../Header' +import Chat from './Chat' +import Preview from './Preview' + +function convertParams( + inputs: Record, +): Record { + return Object.fromEntries( + Object.entries(inputs).map(([key, value]) => { + try { + value = JSON.parse(value) + } catch (e) { + // Do nothing + } + return [key, value] + }), + ) +} + +export default function Playground({ + metadata, +}: { + metadata: ConversationMetadata | undefined +}) { + const [mode, setMode] = useState<'preview' | 'chat'>('preview') + const [inputs, setInputs] = useState>({}) + const parameters = useMemo(() => convertParams(inputs), [inputs]) + + const setInput = useCallback( + (param: string, value: string) => { + setInputs({ ...inputs, [param]: value }) + }, + [inputs], + ) + + useEffect(() => { + if (!metadata) return + + // Remove only inputs that are no longer in the metadata, and add new ones + // Leave existing inputs as they are + setInputs( + Object.fromEntries( + Array.from(metadata.parameters).map((param) => { + if (param in inputs) return [param, inputs[param]!] + return [param, ''] + }), + ), + ) + }, [metadata]) + + return ( + <> +
+ {mode === 'chat' && ( + + )} +
+
+
+ Inputs + {Object.keys(inputs).length > 0 ? ( + Object.entries(inputs).map(([param, value]) => ( +
+ {{{param}}} +
+ setInput(param, e.target.value)} + /> +
+
+ )) + ) : ( + + No inputs. Use {{ input_name }} to insert. + + )} +
+
+
+
+ {mode === 'preview' ? ( + setMode('chat')} + /> + ) : ( + + )} +
+
+
+
+ + ) +} diff --git a/packages/web-ui/src/sections/Document/Editor/index.tsx b/packages/web-ui/src/sections/Document/Editor/index.tsx index ead50b2db..e838d5010 100644 --- a/packages/web-ui/src/sections/Document/Editor/index.tsx +++ b/packages/web-ui/src/sections/Document/Editor/index.tsx @@ -1,31 +1,19 @@ 'use client' -import { - ReactNode, - Suspense, - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import { Suspense, useCallback, useEffect, useState } from 'react' import { ConversationMetadata, readMetadata } from '@latitude-data/compiler' -import { Badge, Input, Text } from '$ui/ds/atoms' +import { DropdownMenu } from '$ui/ds/atoms/DropdownMenu' import { DocumentTextEditor, DocumentTextEditorFallback, } from '$ui/ds/molecules' +import { AppLocalStorage, useLocalStorage } from '$ui/lib/hooks/useLocalStorage' import { useCurrentCommit } from '$ui/providers' import { useDebouncedCallback } from 'use-debounce' -function Header({ title, children }: { title: string; children?: ReactNode }) { - return ( -
- {title} - {children} -
- ) -} +import { Header } from './Header' +import Playground from './Playground' export default function DocumentEditor({ document, @@ -37,13 +25,32 @@ export default function DocumentEditor({ readDocument?: (uuid: string) => Promise }) { const [value, setValue] = useState(document) + const [isSaved, setIsSaved] = useState(true) const [metadata, setMetadata] = useState() + const { value: showLineNumbers, setValue: setShowLineNumbers } = + useLocalStorage({ + key: AppLocalStorage.editorLineNumbers, + defaultValue: true, + }) + const { value: wrapText, setValue: setWrapText } = useLocalStorage({ + key: AppLocalStorage.editorWrapText, + defaultValue: true, + }) + const { value: showMinimap, setValue: setShowMinimap } = useLocalStorage({ + key: AppLocalStorage.editorMinimap, + defaultValue: false, + }) + const { commit } = useCurrentCommit() - const debouncedSave = useDebouncedCallback(saveDocumentContent, 2_000) + const debouncedSave = useDebouncedCallback((val: string) => { + saveDocumentContent(val) + setIsSaved(true) + }, 2_000) const onChange = useCallback((value: string) => { + setIsSaved(false) setValue(value) debouncedSave(value) }, []) @@ -55,15 +62,32 @@ export default function DocumentEditor({ }).then(setMetadata) }, [value, readDocument]) - const inputs = useMemo(() => { - if (!metadata) return [] - return Array.from(metadata.parameters) - }, [metadata]) - return (
-
+
+ setShowLineNumbers(!showLineNumbers), + checked: showLineNumbers, + }, + { + label: 'Wrap text', + onClick: () => setWrapText(!wrapText), + checked: wrapText, + }, + { + label: 'Show minimap', + onClick: () => setShowMinimap(!showMinimap), + checked: showMinimap, + }, + ]} + side='bottom' + align='end' + /> +
}>
-
-
-
-
- Inputs - {inputs.length > 0 ? ( - inputs.map((param, idx) => ( -
- - {{{param}}} - - -
- )) - ) : ( - - No inputs. Use {{ input_name }} to insert. - - )} -
-
+
+
) diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/index.tsx index 37b87e3d8..a61a595b9 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/index.tsx @@ -168,6 +168,14 @@ const NodeHeaderWrapper = forwardRef(function Foo( controlledOpen={actionsOpen} onOpenChange={setActionsOpen} options={actions} + triggerButtonProps={{ + size: 'small', + variant: 'ghost', + iconProps: { + name: 'ellipsisVertical', + color: 'foregroundMuted', + }, + }} side='bottom' align='end' /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f41472591..d3d856343 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,9 @@ importers: react-resizable-panels: specifier: ^2.0.22 version: 2.0.22(react-dom@18.3.0)(react@18.3.0) + react-textarea-autosize: + specifier: ^8.5.3 + version: 8.5.3(@types/react@18.3.0)(react@18.3.0) tailwind-merge: specifier: ^2.4.0 version: 2.4.0 @@ -441,6 +444,9 @@ importers: specifier: ^4.5.4 version: 4.5.4(@types/react@18.3.0)(react@18.3.0) devDependencies: + '@faker-js/faker': + specifier: ^8.4.1 + version: 8.4.1 '@latitude-data/eslint-config': specifier: workspace:* version: link:../../tools/eslint @@ -706,7 +712,6 @@ packages: engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.14.1 - dev: true /@babel/template@7.24.7: resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} @@ -7834,6 +7839,20 @@ packages: tslib: 2.6.3 dev: false + /react-textarea-autosize@8.5.3(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=18.x + dependencies: + '@babel/runtime': 7.24.8 + react: 18.3.0 + use-composed-ref: 1.3.0(react@18.3.0) + use-latest: 1.2.1(@types/react@18.3.0)(react@18.3.0) + transitivePeerDependencies: + - '@types/react' + dev: false + /react@18.3.0: resolution: {integrity: sha512-RPutkJftSAldDibyrjuku7q11d3oy6wKOyPe5K1HA/HwwrXcEqBdHsLypkC2FFYjP7bPUa6gbzSBhw4sY2JcDg==} engines: {node: '>=0.10.0'} @@ -7911,7 +7930,6 @@ packages: /regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - dev: true /regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} @@ -8894,6 +8912,14 @@ packages: tslib: 2.6.3 dev: false + /use-composed-ref@1.3.0(react@18.3.0): + resolution: {integrity: sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=18.x + dependencies: + react: 18.3.0 + dev: false + /use-debounce@10.0.1(react@18.3.0): resolution: {integrity: sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg==} engines: {node: '>= 16.0.0'} @@ -8912,6 +8938,33 @@ packages: react: 19.0.0-rc-378b305958-20240710 dev: false + /use-isomorphic-layout-effect@1.1.2(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=18.x + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.0 + react: 18.3.0 + dev: false + + /use-latest@1.2.1(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=18.x + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.0 + react: 18.3.0 + use-isomorphic-layout-effect: 1.1.2(@types/react@18.3.0)(react@18.3.0) + dev: false + /use-sidecar@1.1.2(@types/react@18.3.0)(react@18.3.0): resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'}