diff --git a/.claude/commands/acp-compile.md b/.claude/commands/acp-compile.md new file mode 100644 index 000000000..779a4b5c9 --- /dev/null +++ b/.claude/commands/acp-compile.md @@ -0,0 +1,51 @@ +--- +description: Submit a plan file to ACP for execution as an AgenticSession on the cluster. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Steps + +1. **Locate the plan file**: + - If `$ARGUMENTS` is a non-empty file path, use that file + - If `$ARGUMENTS` is empty, find the most recently modified `.md` file in `.claude/plans/` + - Read the plan file contents — this becomes the `initial_prompt` + - If no plan file is found, stop and ask the user to provide a path + +2. **Get repository info**: + - Run `git remote get-url origin` to get the repo URL + - Run `git branch --show-current` to get the current branch + +3. **Build the prompt**: + - Prepend a context header to the plan contents: + ``` + You are executing a plan that was compiled and submitted to ACP. + Repository: {repo_url} + Branch: {branch} + + --- + + {plan_file_contents} + ``` + +4. **Create the session**: + - Call the `acp_create_session` MCP tool with: + - `initial_prompt`: the assembled prompt from step 3 + - `repos`: `["{repo_url}"]` + - `display_name`: `"Compiled: {plan_file_basename}"` + - `interactive`: `false` + - `timeout`: `1800` + - If the tool returns `created: false`, print the error message and stop + +5. **Report results**: + - Print the session name and project from the response + - Print follow-up commands: + ``` + Check status: acp_list_sessions(project="...") + View logs: acp_get_session_logs(project="...", session="...") + ``` + - Do NOT wait for the session to complete — return immediately diff --git a/.claude/commands/cypress-demo.md b/.claude/commands/cypress-demo.md new file mode 100644 index 000000000..14a6edca1 --- /dev/null +++ b/.claude/commands/cypress-demo.md @@ -0,0 +1,285 @@ +--- +description: Create a Cypress-based video demo for a feature branch with cursor, click effects, and captions. +--- + +# /cypress-demo Command + +Create a polished Cypress demo test that records a human-paced video walkthrough of UI features on the current branch. + +## Usage + +``` +/cypress-demo # Auto-detect features from branch diff +/cypress-demo chat input refactoring # Describe what to demo +``` + +## User Input + +```text +$ARGUMENTS +``` + +## Behavior + +When invoked, Claude will create a Cypress test file in `e2e/cypress/e2e/` that records a demo video with: + +- **Synthetic cursor** (white dot) that glides smoothly to each interaction target +- **Click ripple** (blue expanding ring) on every click action +- **Caption bar** (compact dark bar at top of viewport) describing each step +- **Human-paced timing** so every action is clearly visible +- **`--no-runner-ui`** flag to exclude the Cypress sidebar from the recording + +### 1. Determine what to demo + +- If `$ARGUMENTS` is provided, use it as the demo description +- If empty, run `git diff main..HEAD --stat` to identify changed files and infer features +- Read the changed/new component files to understand what UI to showcase +- Ask the user if clarification is needed on which features to highlight + +### 2. Check prerequisites + +- Verify `e2e/.env.test` or `e2e/.env` exists with `TEST_TOKEN` +- Check if `ANTHROPIC_API_KEY` is available (needed if the demo requires Running state for workflows, agents, or commands) +- Verify the kind cluster is up: `kubectl get pods -n ambient-code` +- Verify the frontend is accessible: `curl -s -o /dev/null -w "%{http_code}" http://localhost` +- If the frontend was rebuilt from this branch, verify imagePullPolicy is `Never` or `IfNotPresent` + +### 3. Create the demo test file + +Create `e2e/cypress/e2e/-demo.cy.ts` using the template structure below. + +#### Required helpers (copy into every demo file) + +```typescript +// Timing constants — adjust per demo, aim for ~2 min total video +const LONG = 3200 // hold on important visuals +const PAUSE = 2400 // standard pause between actions +const SHORT = 1600 // brief pause after small actions +const TYPE_DELAY = 80 // ms per keystroke + +// Target first element (session page renders desktop + mobile layout) +const chatInput = () => cy.get('textarea[placeholder*="message"]').first() + +// Caption: compact bar at TOP of viewport +function caption(text: string) { + cy.document().then((doc) => { + let el = doc.getElementById('demo-caption') + if (!el) { + el = doc.createElement('div') + el.id = 'demo-caption' + el.style.cssText = [ + 'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99998', + 'background:rgba(0,0,0,0.80)', 'color:#fff', 'font-size:14px', + 'font-weight:500', 'font-family:system-ui,-apple-system,sans-serif', + 'padding:6px 20px', 'text-align:center', 'letter-spacing:0.2px', + 'pointer-events:none', 'transition:opacity 0.4s ease', + ].join(';') + doc.body.appendChild(el) + } + el.textContent = text + el.style.opacity = '1' + }) +} + +function clearCaption() { + cy.document().then((doc) => { + const el = doc.getElementById('demo-caption') + if (el) el.style.opacity = '0' + }) +} + +// Synthetic cursor + click ripple +function initCursor() { + cy.document().then((doc) => { + if (doc.getElementById('demo-cursor')) return + const cursor = doc.createElement('div') + cursor.id = 'demo-cursor' + cursor.style.cssText = [ + 'position:fixed', 'z-index:99999', 'pointer-events:none', + 'width:20px', 'height:20px', 'border-radius:50%', + 'background:rgba(255,255,255,0.9)', 'border:2px solid #333', + 'box-shadow:0 0 6px rgba(0,0,0,0.4)', + 'transform:translate(-50%,-50%)', + 'transition:left 0.5s cubic-bezier(0.25,0.1,0.25,1), top 0.5s cubic-bezier(0.25,0.1,0.25,1)', + 'left:-40px', 'top:-40px', + ].join(';') + doc.body.appendChild(cursor) + const ripple = doc.createElement('div') + ripple.id = 'demo-ripple' + ripple.style.cssText = [ + 'position:fixed', 'z-index:99999', 'pointer-events:none', + 'width:40px', 'height:40px', 'border-radius:50%', + 'border:3px solid rgba(59,130,246,0.8)', + 'transform:translate(-50%,-50%) scale(0)', + 'opacity:0', 'left:-40px', 'top:-40px', + ].join(';') + doc.body.appendChild(ripple) + const style = doc.createElement('style') + style.textContent = ` + @keyframes demo-ripple-anim { + 0% { transform: translate(-50%,-50%) scale(0); opacity: 1; } + 100% { transform: translate(-50%,-50%) scale(2.5); opacity: 0; } + } + ` + doc.head.appendChild(style) + }) +} + +// Move cursor smoothly to element center +function moveTo(selector: string, options?: { first?: boolean }) { + const chain = options?.first ? cy.get(selector).first() : cy.get(selector) + chain.then(($el) => { + const rect = $el[0].getBoundingClientRect() + cy.document().then((doc) => { + const cursor = doc.getElementById('demo-cursor') + if (cursor) { + cursor.style.left = `${rect.left + rect.width / 2}px` + cursor.style.top = `${rect.top + rect.height / 2}px` + } + }) + cy.wait(600) + }) +} + +function moveToText(text: string, tag?: string) { + const chain = tag ? cy.contains(tag, text) : cy.contains(text) + chain.then(($el) => { + const rect = $el[0].getBoundingClientRect() + cy.document().then((doc) => { + const cursor = doc.getElementById('demo-cursor') + if (cursor) { + cursor.style.left = `${rect.left + rect.width / 2}px` + cursor.style.top = `${rect.top + rect.height / 2}px` + } + }) + cy.wait(600) + }) +} + +function moveToEl($el: JQuery) { + const rect = $el[0].getBoundingClientRect() + cy.document().then((doc) => { + const cursor = doc.getElementById('demo-cursor') + if (cursor) { + cursor.style.left = `${rect.left + rect.width / 2}px` + cursor.style.top = `${rect.top + rect.height / 2}px` + } + }) + cy.wait(600) +} + +function clickEffect() { + cy.document().then((doc) => { + const cursor = doc.getElementById('demo-cursor') + const ripple = doc.getElementById('demo-ripple') + if (cursor && ripple) { + ripple.style.left = cursor.style.left + ripple.style.top = cursor.style.top + ripple.style.animation = 'none' + void ripple.offsetHeight + ripple.style.animation = 'demo-ripple-anim 0.5s ease-out forwards' + } + }) +} + +// Compound: move → ripple → click +function cursorClickText(text: string, tag?: string, options?: { force?: boolean }) { + moveToText(text, tag) + clickEffect() + const chain = tag ? cy.contains(tag, text) : cy.contains(text) + chain.click({ force: options?.force }) +} +``` + +#### Test structure + +```typescript +describe(' Demo', () => { + const workspaceName = `demo-${Date.now()}` + + // ... helpers above ... + + Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('Minified React error') || err.message.includes('Hydration')) { + return false + } + return true + }) + + after(() => { + if (!Cypress.env('KEEP_WORKSPACES')) { + const token = Cypress.env('TEST_TOKEN') + cy.request({ + method: 'DELETE', + url: `/api/projects/${workspaceName}`, + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }) + } + }) + + it('demonstrates ', () => { + // ... single continuous test for one video file ... + }) +}) +``` + +### 4. Key patterns to follow + +| Pattern | Rule | +|---------|------| +| **Dual layout** | Session page renders desktop + mobile. Always use `.first()` on element queries that match both | +| **Caption scoping** | When asserting page content with `cy.contains`, scope to a tag (e.g., `cy.contains('p', 'text')`) to avoid matching the caption overlay | +| **Workspace setup** | Create workspace → poll `/api/projects/:name` until 200 → configure runner-secrets if API key needed | +| **Running state** | If demo needs agents/commands, configure `ANTHROPIC_API_KEY` via runner-secrets, select a workflow, and wait for `textarea[placeholder*="attach"]` (Running placeholder) with 180s timeout | +| **Operator pull policy** | For kind clusters, set `IMAGE_PULL_POLICY=IfNotPresent` on the operator to avoid re-pulling the 879MB runner image every session | +| **File attachment** | Use `cy.get('input[type="file"]').first().selectFile({...}, { force: true })` with a `Cypress.Buffer` — no real file needed | +| **Caption position** | Always `top:0` — bottom position obscures the chat toolbar | +| **Timing** | Aim for ~2 min total. LONG=3.2s, PAUSE=2.4s, SHORT=1.6s, TYPE_DELAY=80ms. Adjust if video feels too fast or slow | +| **Video output** | `e2e/cypress/videos/.cy.ts.mp4` at 2560x1440 (Retina) | + +### 5. Run the demo + +```bash +cd e2e +npx cypress run --no-runner-ui --spec "cypress/e2e/-demo.cy.ts" +``` + +- Verify the video plays at human-readable speed +- Check that captions don't overlap important UI elements +- Re-run and iterate if needed — adjust timing or add/remove steps + +### 6. Commit and push + +- Commit the demo test file and any config changes (`cypress.config.ts`) +- Push to the current branch +- If a PR exists, note the demo in the PR description + +## Reference implementation + +See `e2e/cypress/e2e/chatbox-demo.cy.ts` for a complete working example that demonstrates: +- Workspace creation, session creation +- WelcomeExperience (streaming text, workflow cards) +- Workflow selection ("Fix a bug") with Running state wait +- File attachments (AttachmentPreview) +- Autocomplete popovers (@agents, /commands) with real workflow data +- Message queueing (QueuedMessageBubble) +- Message history and queued message editing +- Settings dropdown +- Breadcrumb navigation + +## Config requirements + +`e2e/cypress.config.ts` must load `.env.test` and wire `TEST_TOKEN`: + +```typescript +// Load env files: .env.local > .env > .env.test +const envFiles = ['.env.local', '.env', '.env.test'].map(f => path.resolve(__dirname, f)) +for (const envFile of envFiles) { + if (fs.existsSync(envFile)) { dotenv.config({ path: envFile }) } +} + +// In setupNodeEvents: +config.env.TEST_TOKEN = process.env.CYPRESS_TEST_TOKEN || process.env.TEST_TOKEN || config.env.TEST_TOKEN || '' +config.env.ANTHROPIC_API_KEY = process.env.CYPRESS_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '' +``` diff --git a/.gitignore b/.gitignore index cf812c6bb..1dc813586 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ celerybeat-schedule # Environments .env .env.uat +.dev-bootstrap.env .venv env/ venv/ diff --git a/Makefile b/Makefile index fa3289aad..28b3ffc15 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ .PHONY: local-dev-token .PHONY: local-logs local-logs-backend local-logs-frontend local-logs-operator local-shell local-shell-frontend .PHONY: local-test local-test-dev local-test-quick test-all local-url local-troubleshoot local-port-forward local-stop-port-forward -.PHONY: push-all registry-login setup-hooks remove-hooks check-minikube check-kind check-kubectl +.PHONY: push-all registry-login setup-hooks remove-hooks check-minikube check-kind check-kubectl dev-bootstrap .PHONY: e2e-test e2e-setup e2e-clean deploy-langfuse-openshift .PHONY: setup-minio minio-console minio-logs minio-status .PHONY: validate-makefile lint-makefile check-shell makefile-health @@ -593,6 +593,11 @@ kind-up: check-kind check-kubectl ## Start kind cluster with Quay.io images (pro GOOGLE_APPLICATION_CREDENTIALS="$(GOOGLE_APPLICATION_CREDENTIALS)" \ ./scripts/setup-vertex-kind.sh; \ fi + @if [ -f .dev-bootstrap.env ]; then \ + echo "$(COLOR_BLUE)▶$(COLOR_RESET) Bootstrapping developer workspace..."; \ + ./scripts/bootstrap-workspace.sh || \ + echo "$(COLOR_YELLOW)⚠$(COLOR_RESET) Bootstrap failed (non-fatal). Run 'make dev-bootstrap' manually."; \ + fi @echo "" @echo "$(COLOR_BOLD)Access the platform:$(COLOR_RESET)" @echo " Run in another terminal: $(COLOR_BLUE)make kind-port-forward$(COLOR_RESET)" @@ -624,6 +629,9 @@ kind-port-forward: check-kubectl ## Port-forward kind services (for remote Podma (kubectl port-forward -n ambient-code svc/backend-service 8081:8080 >/dev/null 2>&1 &); \ wait +dev-bootstrap: check-kubectl ## Bootstrap developer workspace with API key and integrations + @./scripts/bootstrap-workspace.sh + ##@ E2E Testing (Portable) test-e2e: ## Run e2e tests against current CYPRESS_BASE_URL diff --git a/components/frontend/next.config.js b/components/frontend/next.config.js index 5ee112947..b6f3dafe1 100644 --- a/components/frontend/next.config.js +++ b/components/frontend/next.config.js @@ -1,6 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', + turbopack: { + root: __dirname, // Silence "inferred workspace root" warning in monorepo + }, experimental: { instrumentationHook: true, } diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx index 27cfc3f12..17e4201ea 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { GitBranch, X, Link, Loader2, CloudUpload, ChevronDown, ChevronRight } from "lucide-react"; import { AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; @@ -42,6 +42,18 @@ export function RepositoriesAccordion({ const totalContextItems = repositories.length + uploadedFiles.length; + // Pulse the badge when count increases + const prevCount = useRef(totalContextItems); + const [badgePulse, setBadgePulse] = useState(false); + useEffect(() => { + if (totalContextItems > prevCount.current) { + setBadgePulse(true); + const timer = setTimeout(() => setBadgePulse(false), 1500); + return () => clearTimeout(timer); + } + prevCount.current = totalContextItems; + }, [totalContextItems]); + const handleRemoveRepo = async (repoName: string) => { if (confirm(`Remove repository ${repoName}?`)) { setRemovingRepo(repoName); @@ -72,7 +84,14 @@ export function RepositoriesAccordion({ Context {totalContextItems > 0 && ( - + {totalContextItems} )} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/upload-file-modal.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/upload-file-modal.tsx index 6e6ee9218..0539fef06 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/upload-file-modal.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/upload-file-modal.tsx @@ -16,21 +16,8 @@ import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Alert, AlertDescription } from "@/components/ui/alert"; -// Maximum file sizes based on type -// Documents (text files): 700KB limit - no base64 encoding overhead -// Images: 3MB upload limit - realistic compression to 350KB target -const MAX_DOCUMENT_SIZE = 700 * 1024; // 700KB for documents -const MAX_IMAGE_SIZE = 3 * 1024 * 1024; // 3MB for images (server will compress to 350KB) - -// Determine if a file is an image based on MIME type -const isImageFile = (fileType: string): boolean => { - return fileType.startsWith('image/'); -}; - -// Get the appropriate max file size based on file type -const getMaxFileSize = (fileType: string): number => { - return isImageFile(fileType) ? MAX_IMAGE_SIZE : MAX_DOCUMENT_SIZE; -}; +// Maximum file size: 10MB for all file types +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB unified limit const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; @@ -131,14 +118,10 @@ export function UploadFileModal({ // Use setTimeout to allow UI to update with loading state setTimeout(() => { - const fileType = file.type || 'application/octet-stream'; - const maxSize = getMaxFileSize(fileType); - const fileTypeLabel = isImageFile(fileType) ? 'images' : 'documents'; - - // Check file size based on type - if (file.size > maxSize) { + // Check file size against unified 10MB limit + if (file.size > MAX_FILE_SIZE) { setFileSizeError( - `File size (${formatFileSize(file.size)}) exceeds maximum allowed size of ${formatFileSize(maxSize)} for ${fileTypeLabel}` + `File size (${formatFileSize(file.size)}) exceeds maximum allowed size of ${formatFileSize(MAX_FILE_SIZE)}` ); setSelectedFile(null); if (fileInputRef.current) { @@ -166,7 +149,7 @@ export function UploadFileModal({ Upload File Upload files to your workspace from your local machine or a URL. Files will be available in - the file-uploads folder. Maximum file size: {formatFileSize(MAX_IMAGE_SIZE)} for images, {formatFileSize(MAX_DOCUMENT_SIZE)} for documents. + the file-uploads folder. Maximum file size: {formatFileSize(MAX_FILE_SIZE)}. diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index dd442824b..9ef2be160 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -2498,6 +2498,12 @@ export default function ProjectSessionDetailPage({ userHasInteracted={userHasInteracted} queuedMessages={sessionQueue.messages} hasRealMessages={hasRealMessages} + onPasteImage={async (file: File) => { + await uploadFileMutation.mutateAsync({ type: "local", file }); + }} + onCancelQueuedMessage={(id) => sessionQueue.cancelMessage(id)} + onUpdateQueuedMessage={(id, content) => sessionQueue.updateMessage(id, content)} + onClearQueue={() => sessionQueue.clearMessages()} welcomeExperienceComponent={ { + await uploadFileMutation.mutateAsync({ type: "local", file }); + }} + onCancelQueuedMessage={(id) => sessionQueue.cancelMessage(id)} + onUpdateQueuedMessage={(id, content) => sessionQueue.updateMessage(id, content)} + onClearQueue={() => sessionQueue.clearMessages()} welcomeExperienceComponent={ void; +}; + +const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +export const AttachmentPreview: React.FC = ({ + attachments, + onRemove, +}) => { + if (attachments.length === 0) return null; + + return ( +
+ {attachments.map((attachment) => { + const isImage = attachment.file.type.startsWith("image/"); + + return ( +
+ {isImage && attachment.preview ? ( + {attachment.file.name} + ) : isImage ? ( + + ) : ( + + )} + +
+

+ {attachment.file.name} +

+

+ {formatFileSize(attachment.file.size)} +

+ {attachment.error && ( +

{attachment.error}

+ )} +
+ + {attachment.uploading ? ( +
+ +
+ ) : ( + + )} +
+ ); + })} +
+ ); +}; + +export default AttachmentPreview; diff --git a/components/frontend/src/components/chat/AutocompletePopover.tsx b/components/frontend/src/components/chat/AutocompletePopover.tsx new file mode 100644 index 000000000..ec8667c92 --- /dev/null +++ b/components/frontend/src/components/chat/AutocompletePopover.tsx @@ -0,0 +1,153 @@ +"use client"; + +import React, { useRef, useEffect } from "react"; +import { Users, Terminal } from "lucide-react"; +import type { AutocompleteItem, AutocompleteAgent, AutocompleteCommand } from "@/hooks/use-autocomplete"; + +export type { AutocompleteAgent, AutocompleteCommand }; + +export type AutocompletePopoverProps = { + open: boolean; + type: "agent" | "command" | null; + filter: string; + selectedIndex: number; + items: AutocompleteItem[]; + onSelect: (item: AutocompleteItem) => void; + onSelectedIndexChange: (index: number) => void; + onClose: () => void; +}; + +export const AutocompletePopover: React.FC = ({ + open, + type, + filter, + selectedIndex, + items, + onSelect, + onSelectedIndexChange, + onClose, +}) => { + const containerRef = useRef(null); + const selectedItemRef = useRef(null); + + // Scroll selected item into view + useEffect(() => { + if (selectedItemRef.current) { + selectedItemRef.current.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [selectedIndex]); + + // Click outside handler + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + if (open) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [open, onClose]); + + if (!open || !type) return null; + + const getShortName = (name: string) => name.split(" - ")[0]; + const typeLabel = type === "agent" ? "agents" : "commands"; + const TypeIcon = type === "agent" ? Users : Terminal; + + return ( +
+ {/* Header */} +
+ + + {type === "agent" ? "Mention an agent" : "Run a command"} + + {filter && ( + + "{filter}" + + )} +
+ + {items.length === 0 ? ( +
+ No {typeLabel} found + {filter && Try a different search} +
+ ) : ( +
+ {items.map((item, index) => { + const isAgent = type === "agent"; + const agent = isAgent ? (item as AutocompleteAgent) : null; + const cmd = !isAgent ? (item as AutocompleteCommand) : null; + const isSelected = index === selectedIndex; + + return ( +
onSelect(item)} + onMouseEnter={() => onSelectedIndexChange(index)} + > +
+ {isAgent && ( +
+ + {getShortName(agent!.name).charAt(0).toUpperCase()} + +
+ )} +
+
+ {isAgent ? `@${getShortName(agent!.name)}` : cmd!.slashCommand} +
+
+ {isAgent ? agent!.name : cmd!.name} +
+
+
+ {item.description && ( +

+ {item.description} +

+ )} +
+ ); + })} +
+ )} + + {/* Footer hint */} +
+ + ↑↓ navigate + + + Tab select + + + Esc close + +
+
+ ); +}; + +export default AutocompletePopover; diff --git a/components/frontend/src/components/chat/ChatInputBox.tsx b/components/frontend/src/components/chat/ChatInputBox.tsx new file mode 100644 index 000000000..7b4ad80f0 --- /dev/null +++ b/components/frontend/src/components/chat/ChatInputBox.tsx @@ -0,0 +1,706 @@ +"use client"; + +import React, { useState, useRef, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Loader2, + Settings, + Terminal, + Users, + Paperclip, + Clock, + X, + Pencil, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useToast } from "@/hooks/use-toast"; +import { useResizeTextarea } from "@/hooks/use-resize-textarea"; +import { useAutocomplete } from "@/hooks/use-autocomplete"; +import type { AutocompleteAgent, AutocompleteCommand } from "@/hooks/use-autocomplete"; +import { AutocompletePopover } from "./AutocompletePopover"; +import { AttachmentPreview, type PendingAttachment } from "./AttachmentPreview"; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +export type ChatInputBoxProps = { + value: string; + onChange: (value: string) => void; + onSend: () => Promise; + onInterrupt: () => Promise; + onPasteImage?: (file: File) => Promise; + isRunActive?: boolean; + isSending?: boolean; + disabled?: boolean; + placeholder?: string; + agents?: AutocompleteAgent[]; + commands?: AutocompleteCommand[]; + onCommandClick?: (slashCommand: string) => void; + showSystemMessages?: boolean; + onShowSystemMessagesChange?: (show: boolean) => void; + queuedCount?: number; + sessionPhase?: string; + onContinue?: () => void; + messageHistory?: string[]; + queuedMessageHistory?: Array<{ id: string; content: string }>; + onUpdateQueuedMessage?: (messageId: string, newContent: string) => void; + onCancelQueuedMessage?: (messageId: string) => void; + onClearQueue?: () => void; +}; + +type HistoryEntry = { + text: string; + queuedId?: string; +}; + +/** Generate a preview data-URL for image files. */ +function generatePreview(file: File): Promise { + return new Promise((resolve) => { + if (!file.type.startsWith("image/")) { + resolve(undefined); + return; + } + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target?.result as string); + reader.onerror = () => resolve(undefined); + reader.readAsDataURL(file); + }); +} + +function makeAttachmentId() { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +// --------------------------------------------------------------------------- +// Toolbar item-list popover — shared between Agents and Commands +// --------------------------------------------------------------------------- + +type ToolbarItemListProps = { + items: AutocompleteAgent[] | AutocompleteCommand[]; + type: "agent" | "command"; + onInsertAgent?: (name: string) => void; + onRunCommand?: (slashCommand: string) => void; +}; + +const ToolbarItemList: React.FC = ({ items, type, onInsertAgent, onRunCommand }) => { + const heading = type === "agent" ? "Available Agents" : "Available Commands"; + const subtitle = type === "agent" + ? "Mention agents in your message to collaborate with them" + : "Run workflow commands to perform specific actions"; + const emptyLabel = type === "agent" ? "No agents available" : "No commands available"; + + return ( +
+
+

{heading}

+

{subtitle}

+
+
+ {items.length === 0 ? ( +

{emptyLabel}

+ ) : ( + items.map((item) => { + const isAgent = type === "agent"; + const agent = isAgent ? (item as AutocompleteAgent) : null; + const cmd = !isAgent ? (item as AutocompleteCommand) : null; + const shortName = isAgent ? agent!.name.split(" - ")[0] : ""; + + return ( +
+
+

{item.name}

+ {isAgent ? ( + + ) : ( + + )} +
+ {item.description && ( +

{item.description}

+ )} +
+ ); + }) + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// ChatInputBox +// --------------------------------------------------------------------------- + +export const ChatInputBox: React.FC = ({ + value, + onChange, + onSend, + onInterrupt, + onPasteImage, + isRunActive = false, + isSending = false, + disabled = false, + placeholder, + agents = [], + commands = [], + onCommandClick, + showSystemMessages = false, + onShowSystemMessagesChange, + queuedCount = 0, + sessionPhase = "", + onContinue, + messageHistory = [], + queuedMessageHistory = [], + onUpdateQueuedMessage, + onCancelQueuedMessage, + onClearQueue, +}) => { + const { toast } = useToast(); + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const { textareaHeight, handleResizeStart } = useResizeTextarea(); + + // Phase-derived state + const isTerminalState = ["Completed", "Failed", "Stopped"].includes(sessionPhase); + const isCreating = ["Creating", "Pending"].includes(sessionPhase); + + // Autocomplete (consolidated via hook) + const autocomplete = useAutocomplete({ agents, commands }); + + // Attachment state + const [pendingAttachments, setPendingAttachments] = useState([]); + + // Popover states + const [agentsPopoverOpen, setAgentsPopoverOpen] = useState(false); + const [commandsPopoverOpen, setCommandsPopoverOpen] = useState(false); + + // Interrupting state + const [interrupting, setInterrupting] = useState(false); + + // Prompt history state + const [historyIndex, setHistoryIndex] = useState(-1); + const [draftInput, setDraftInput] = useState(""); + const [editingQueuedId, setEditingQueuedId] = useState(null); + + // Combined history: queued (unsent) first, then sent messages — all newest-first + const combinedHistory = useMemo(() => { + const queued = queuedMessageHistory.map((m) => ({ text: m.content, queuedId: m.id })); + const sent = messageHistory.map((text) => ({ text })); + return [...queued, ...sent]; + }, [queuedMessageHistory, messageHistory]); + + const resetHistory = () => { + setHistoryIndex(-1); + setDraftInput(""); + setEditingQueuedId(null); + }; + + // Dynamic placeholder + const getPlaceholder = () => { + if (placeholder) return placeholder; + if (isTerminalState) return "Type a message to resume this session..."; + if (isCreating) return "Type a message (will be queued until session starts)..."; + if (isRunActive) return "Type a message (will be queued)..."; + return "Type a message... (\u{1F4CE} attach \u00B7 \u2318V paste \u00B7 \u2191 history \u00B7 Enter send \u00B7 Shift+Enter newline)"; + }; + + // Handle paste events for images + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const items = Array.from(e.clipboardData?.items || []); + const imageItems = items.filter((item) => item.type.startsWith("image/")); + + if (imageItems.length > 0 && onPasteImage) { + e.preventDefault(); + + for (const item of imageItems) { + const file = item.getAsFile(); + if (!file) continue; + if (file.size > MAX_FILE_SIZE) { + toast({ + variant: "destructive", + title: "File too large", + description: `Maximum file size is 10MB. Your file is ${(file.size / (1024 * 1024)).toFixed(1)}MB.`, + }); + continue; + } + + const renamedFile = + file.name === "image.png" || file.name === "image.jpg" + ? new File( + [file], + `paste-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.png`, + { type: file.type } + ) + : file; + + const preview = await generatePreview(renamedFile); + setPendingAttachments((prev) => [ + ...prev, + { id: makeAttachmentId(), file: renamedFile, preview }, + ]); + } + } + }, + [onPasteImage, toast] + ); + + const handleRemoveAttachment = (attachmentId: string) => { + setPendingAttachments((prev) => prev.filter((a) => a.id !== attachmentId)); + }; + + // Handle native file picker selection + const handleFileSelect = useCallback( + async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + for (const file of files) { + if (file.size > MAX_FILE_SIZE) { + toast({ + variant: "destructive", + title: "File too large", + description: `Maximum file size is 10MB. "${file.name}" is ${(file.size / (1024 * 1024)).toFixed(1)}MB.`, + }); + continue; + } + + const preview = await generatePreview(file); + setPendingAttachments((prev) => [ + ...prev, + { id: makeAttachmentId(), file, preview }, + ]); + } + e.target.value = ""; + }, + [toast] + ); + + // Handle input change — delegate autocomplete detection to hook + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + + if (historyIndex >= 0) { + setHistoryIndex(-1); + setDraftInput(""); + } + + autocomplete.handleInputChange(newValue, e.target.selectionStart); + }; + + // Handle key events + const handleKeyDown = async (e: React.KeyboardEvent) => { + // Let autocomplete hook handle its keys first + if (autocomplete.handleKeyDown(e)) { + // If Enter/Tab was pressed, perform the selection + if ((e.key === "Enter" || e.key === "Tab") && autocomplete.filteredItems.length > 0) { + const item = autocomplete.filteredItems[autocomplete.selectedIndex]; + const cursorPos = textareaRef.current?.selectionStart ?? value.length; + const newCursorPos = autocomplete.select(item, value, cursorPos, onChange); + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.selectionStart = newCursorPos; + textareaRef.current.selectionEnd = newCursorPos; + textareaRef.current.focus(); + } + }, 0); + } + return; + } + + // Prompt history: Up arrow + if (e.key === "ArrowUp" && combinedHistory.length > 0) { + const cursorPos = textareaRef.current?.selectionStart ?? 0; + if (cursorPos === 0 || value === "") { + e.preventDefault(); + const newIndex = historyIndex + 1; + if (newIndex < combinedHistory.length) { + if (historyIndex === -1) setDraftInput(value); + setHistoryIndex(newIndex); + const entry = combinedHistory[newIndex]; + onChange(entry.text); + setEditingQueuedId(entry.queuedId ?? null); + } + return; + } + } + + // Prompt history: Down arrow + if (e.key === "ArrowDown" && historyIndex >= 0) { + const cursorAtEnd = (textareaRef.current?.selectionStart ?? 0) === value.length; + if (cursorAtEnd || value === "") { + e.preventDefault(); + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + if (newIndex < 0) { + onChange(draftInput); + setEditingQueuedId(null); + setDraftInput(""); + } else { + const entry = combinedHistory[newIndex]; + onChange(entry.text); + setEditingQueuedId(entry.queuedId ?? null); + } + return; + } + } + + // Escape to cancel editing queued message + if (e.key === "Escape" && editingQueuedId) { + e.preventDefault(); + onChange(draftInput); + resetHistory(); + return; + } + + // Ctrl+Space to manually trigger autocomplete + if (e.key === " " && e.ctrlKey) { + e.preventDefault(); + const cursorPos = textareaRef.current?.selectionStart || 0; + autocomplete.open("agent", cursorPos); + return; + } + + // Enter to send (or update queued message) + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + await handleSendOrUpdate(); + } + }; + + // Upload pending attachments + const uploadPendingAttachments = async (): Promise => { + const toUpload = pendingAttachments.filter((a) => !a.uploading && !a.error); + if (toUpload.length === 0 || !onPasteImage) return true; + + for (const attachment of toUpload) { + setPendingAttachments((prev) => + prev.map((a) => (a.id === attachment.id ? { ...a, uploading: true } : a)) + ); + try { + await onPasteImage(attachment.file); + setPendingAttachments((prev) => + prev.map((a) => (a.id === attachment.id ? { ...a, uploading: false } : a)) + ); + } catch { + setPendingAttachments((prev) => + prev.map((a) => + a.id === attachment.id ? { ...a, uploading: false, error: "Upload failed" } : a + ) + ); + return false; + } + } + return true; + }; + + const hasContent = value.trim() || pendingAttachments.length > 0; + + const handleSendOrUpdate = async () => { + if (!hasContent || isSending) return; + + // If editing a queued message, update it in place + if (editingQueuedId && onUpdateQueuedMessage) { + onUpdateQueuedMessage(editingQueuedId, value.trim()); + onChange(""); + resetHistory(); + setPendingAttachments([]); + toast({ title: "Queued message updated", description: "The queued message has been updated." }); + return; + } + + const uploaded = await uploadPendingAttachments(); + if (!uploaded) return; + + if (isRunActive) { + toast({ title: "Message queued", description: "Your message will be sent when the agent is ready." }); + } + await onSend(); + resetHistory(); + setPendingAttachments([]); + }; + + const handleSendAsNew = async () => { + if (!value.trim() || isSending || !editingQueuedId) return; + onCancelQueuedMessage?.(editingQueuedId); + resetHistory(); + + const uploaded = await uploadPendingAttachments(); + if (!uploaded) return; + + if (isRunActive) { + toast({ + title: "Message queued", + description: "Original cancelled. New message will be sent when the agent is ready.", + }); + } + await onSend(); + setPendingAttachments([]); + }; + + const handleInterrupt = async () => { + setInterrupting(true); + try { + await onInterrupt(); + } finally { + setInterrupting(false); + } + }; + + const getTextareaStyle = () => { + if (editingQueuedId) return "border-blue-400/50 bg-blue-50/30 dark:bg-blue-950/10"; + if (isRunActive) return "border-amber-400/50 bg-amber-50/30 dark:bg-amber-950/10"; + return ""; + }; + + return ( +
+
+ {/* Phase status banner */} + {isCreating && ( +
+ + Session is starting up. Messages will be queued. +
+ )} + {isTerminalState && ( +
+ Session has {sessionPhase.toLowerCase()}. + {onContinue && ( + + )} +
+ )} + + {/* Editing queued message indicator */} + {editingQueuedId && ( +
+ + Editing queued message + +
+ )} + + {/* Attachment preview */} + + + {/* Textarea with autocomplete */} +
+ {/* Resize handle */} +
+
+
+ + {/* Queue indicator with clear button */} + {isRunActive && queuedCount > 0 && ( +
+ + {queuedCount} message{queuedCount > 1 ? "s" : ""} queued + {onClearQueue && ( + + )} +
+ )} + +