Skip to content

Commit 253af3f

Browse files
jeremyederclaude
andauthored
feat: chatbox component extraction with Cypress demo (#633)
## Summary - Holistic reimplementation of the chat input UI, extracting a monolithic MessagesTab (616 lines) into 5 focused components + 2 hooks - Adds a Cypress demo test that records a human-paced video walkthrough of the new components ### New Components (`components/frontend/src/components/chat/`) - **ChatInputBox** — core input with autocomplete, file attach, message queue, resize - **AutocompletePopover** — floating `@agent` and `/command` picker - **AttachmentPreview** — file thumbnail with upload state and delete - **QueuedMessageBubble** — amber-styled queued message display - **WelcomeExperience** — 4 quick-start prompt cards ### New Hooks (`components/frontend/src/hooks/`) - **useAutocomplete** — consolidated autocomplete state and filtering - **useResizeTextarea** — draggable textarea height adjustment ### Demo Test (`e2e/cypress/e2e/chatbox-demo.cy.ts`) Records a video at natural speed showing: workspace creation → welcome cards → prompt prefill → typing → message queueing → settings dropdown → breadcrumb navigation. Run: `npx cypress run --spec "cypress/e2e/chatbox-demo.cy.ts"` Video output: `cypress/videos/chatbox-demo.cy.ts.mp4` Follow-up: #632 (upload progress indicator + display settings) ## Test plan - [ ] `npm run build` passes in `components/frontend/` - [ ] Cypress demo test runs against kind cluster: `make test-e2e` - [ ] Video recorded at `cypress/videos/chatbox-demo.cy.ts.mp4` plays at human-readable speed - [ ] Existing e2e tests still pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 85524dd commit 253af3f

22 files changed

Lines changed: 2292 additions & 579 deletions

File tree

.claude/commands/acp-compile.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
description: Submit a plan file to ACP for execution as an AgenticSession on the cluster.
3+
---
4+
5+
## User Input
6+
7+
```text
8+
$ARGUMENTS
9+
```
10+
11+
## Steps
12+
13+
1. **Locate the plan file**:
14+
- If `$ARGUMENTS` is a non-empty file path, use that file
15+
- If `$ARGUMENTS` is empty, find the most recently modified `.md` file in `.claude/plans/`
16+
- Read the plan file contents — this becomes the `initial_prompt`
17+
- If no plan file is found, stop and ask the user to provide a path
18+
19+
2. **Get repository info**:
20+
- Run `git remote get-url origin` to get the repo URL
21+
- Run `git branch --show-current` to get the current branch
22+
23+
3. **Build the prompt**:
24+
- Prepend a context header to the plan contents:
25+
```
26+
You are executing a plan that was compiled and submitted to ACP.
27+
Repository: {repo_url}
28+
Branch: {branch}
29+
30+
---
31+
32+
{plan_file_contents}
33+
```
34+
35+
4. **Create the session**:
36+
- Call the `acp_create_session` MCP tool with:
37+
- `initial_prompt`: the assembled prompt from step 3
38+
- `repos`: `["{repo_url}"]`
39+
- `display_name`: `"Compiled: {plan_file_basename}"`
40+
- `interactive`: `false`
41+
- `timeout`: `1800`
42+
- If the tool returns `created: false`, print the error message and stop
43+
44+
5. **Report results**:
45+
- Print the session name and project from the response
46+
- Print follow-up commands:
47+
```
48+
Check status: acp_list_sessions(project="...")
49+
View logs: acp_get_session_logs(project="...", session="...")
50+
```
51+
- Do NOT wait for the session to complete — return immediately

.claude/commands/cypress-demo.md

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
---
2+
description: Create a Cypress-based video demo for a feature branch with cursor, click effects, and captions.
3+
---
4+
5+
# /cypress-demo Command
6+
7+
Create a polished Cypress demo test that records a human-paced video walkthrough of UI features on the current branch.
8+
9+
## Usage
10+
11+
```
12+
/cypress-demo # Auto-detect features from branch diff
13+
/cypress-demo chat input refactoring # Describe what to demo
14+
```
15+
16+
## User Input
17+
18+
```text
19+
$ARGUMENTS
20+
```
21+
22+
## Behavior
23+
24+
When invoked, Claude will create a Cypress test file in `e2e/cypress/e2e/` that records a demo video with:
25+
26+
- **Synthetic cursor** (white dot) that glides smoothly to each interaction target
27+
- **Click ripple** (blue expanding ring) on every click action
28+
- **Caption bar** (compact dark bar at top of viewport) describing each step
29+
- **Human-paced timing** so every action is clearly visible
30+
- **`--no-runner-ui`** flag to exclude the Cypress sidebar from the recording
31+
32+
### 1. Determine what to demo
33+
34+
- If `$ARGUMENTS` is provided, use it as the demo description
35+
- If empty, run `git diff main..HEAD --stat` to identify changed files and infer features
36+
- Read the changed/new component files to understand what UI to showcase
37+
- Ask the user if clarification is needed on which features to highlight
38+
39+
### 2. Check prerequisites
40+
41+
- Verify `e2e/.env.test` or `e2e/.env` exists with `TEST_TOKEN`
42+
- Check if `ANTHROPIC_API_KEY` is available (needed if the demo requires Running state for workflows, agents, or commands)
43+
- Verify the kind cluster is up: `kubectl get pods -n ambient-code`
44+
- Verify the frontend is accessible: `curl -s -o /dev/null -w "%{http_code}" http://localhost`
45+
- If the frontend was rebuilt from this branch, verify imagePullPolicy is `Never` or `IfNotPresent`
46+
47+
### 3. Create the demo test file
48+
49+
Create `e2e/cypress/e2e/<feature-name>-demo.cy.ts` using the template structure below.
50+
51+
#### Required helpers (copy into every demo file)
52+
53+
```typescript
54+
// Timing constants — adjust per demo, aim for ~2 min total video
55+
const LONG = 3200 // hold on important visuals
56+
const PAUSE = 2400 // standard pause between actions
57+
const SHORT = 1600 // brief pause after small actions
58+
const TYPE_DELAY = 80 // ms per keystroke
59+
60+
// Target first element (session page renders desktop + mobile layout)
61+
const chatInput = () => cy.get('textarea[placeholder*="message"]').first()
62+
63+
// Caption: compact bar at TOP of viewport
64+
function caption(text: string) {
65+
cy.document().then((doc) => {
66+
let el = doc.getElementById('demo-caption')
67+
if (!el) {
68+
el = doc.createElement('div')
69+
el.id = 'demo-caption'
70+
el.style.cssText = [
71+
'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99998',
72+
'background:rgba(0,0,0,0.80)', 'color:#fff', 'font-size:14px',
73+
'font-weight:500', 'font-family:system-ui,-apple-system,sans-serif',
74+
'padding:6px 20px', 'text-align:center', 'letter-spacing:0.2px',
75+
'pointer-events:none', 'transition:opacity 0.4s ease',
76+
].join(';')
77+
doc.body.appendChild(el)
78+
}
79+
el.textContent = text
80+
el.style.opacity = '1'
81+
})
82+
}
83+
84+
function clearCaption() {
85+
cy.document().then((doc) => {
86+
const el = doc.getElementById('demo-caption')
87+
if (el) el.style.opacity = '0'
88+
})
89+
}
90+
91+
// Synthetic cursor + click ripple
92+
function initCursor() {
93+
cy.document().then((doc) => {
94+
if (doc.getElementById('demo-cursor')) return
95+
const cursor = doc.createElement('div')
96+
cursor.id = 'demo-cursor'
97+
cursor.style.cssText = [
98+
'position:fixed', 'z-index:99999', 'pointer-events:none',
99+
'width:20px', 'height:20px', 'border-radius:50%',
100+
'background:rgba(255,255,255,0.9)', 'border:2px solid #333',
101+
'box-shadow:0 0 6px rgba(0,0,0,0.4)',
102+
'transform:translate(-50%,-50%)',
103+
'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)',
104+
'left:-40px', 'top:-40px',
105+
].join(';')
106+
doc.body.appendChild(cursor)
107+
const ripple = doc.createElement('div')
108+
ripple.id = 'demo-ripple'
109+
ripple.style.cssText = [
110+
'position:fixed', 'z-index:99999', 'pointer-events:none',
111+
'width:40px', 'height:40px', 'border-radius:50%',
112+
'border:3px solid rgba(59,130,246,0.8)',
113+
'transform:translate(-50%,-50%) scale(0)',
114+
'opacity:0', 'left:-40px', 'top:-40px',
115+
].join(';')
116+
doc.body.appendChild(ripple)
117+
const style = doc.createElement('style')
118+
style.textContent = `
119+
@keyframes demo-ripple-anim {
120+
0% { transform: translate(-50%,-50%) scale(0); opacity: 1; }
121+
100% { transform: translate(-50%,-50%) scale(2.5); opacity: 0; }
122+
}
123+
`
124+
doc.head.appendChild(style)
125+
})
126+
}
127+
128+
// Move cursor smoothly to element center
129+
function moveTo(selector: string, options?: { first?: boolean }) {
130+
const chain = options?.first ? cy.get(selector).first() : cy.get(selector)
131+
chain.then(($el) => {
132+
const rect = $el[0].getBoundingClientRect()
133+
cy.document().then((doc) => {
134+
const cursor = doc.getElementById('demo-cursor')
135+
if (cursor) {
136+
cursor.style.left = `${rect.left + rect.width / 2}px`
137+
cursor.style.top = `${rect.top + rect.height / 2}px`
138+
}
139+
})
140+
cy.wait(600)
141+
})
142+
}
143+
144+
function moveToText(text: string, tag?: string) {
145+
const chain = tag ? cy.contains(tag, text) : cy.contains(text)
146+
chain.then(($el) => {
147+
const rect = $el[0].getBoundingClientRect()
148+
cy.document().then((doc) => {
149+
const cursor = doc.getElementById('demo-cursor')
150+
if (cursor) {
151+
cursor.style.left = `${rect.left + rect.width / 2}px`
152+
cursor.style.top = `${rect.top + rect.height / 2}px`
153+
}
154+
})
155+
cy.wait(600)
156+
})
157+
}
158+
159+
function moveToEl($el: JQuery<HTMLElement>) {
160+
const rect = $el[0].getBoundingClientRect()
161+
cy.document().then((doc) => {
162+
const cursor = doc.getElementById('demo-cursor')
163+
if (cursor) {
164+
cursor.style.left = `${rect.left + rect.width / 2}px`
165+
cursor.style.top = `${rect.top + rect.height / 2}px`
166+
}
167+
})
168+
cy.wait(600)
169+
}
170+
171+
function clickEffect() {
172+
cy.document().then((doc) => {
173+
const cursor = doc.getElementById('demo-cursor')
174+
const ripple = doc.getElementById('demo-ripple')
175+
if (cursor && ripple) {
176+
ripple.style.left = cursor.style.left
177+
ripple.style.top = cursor.style.top
178+
ripple.style.animation = 'none'
179+
void ripple.offsetHeight
180+
ripple.style.animation = 'demo-ripple-anim 0.5s ease-out forwards'
181+
}
182+
})
183+
}
184+
185+
// Compound: move → ripple → click
186+
function cursorClickText(text: string, tag?: string, options?: { force?: boolean }) {
187+
moveToText(text, tag)
188+
clickEffect()
189+
const chain = tag ? cy.contains(tag, text) : cy.contains(text)
190+
chain.click({ force: options?.force })
191+
}
192+
```
193+
194+
#### Test structure
195+
196+
```typescript
197+
describe('<Feature> Demo', () => {
198+
const workspaceName = `demo-${Date.now()}`
199+
200+
// ... helpers above ...
201+
202+
Cypress.on('uncaught:exception', (err) => {
203+
if (err.message.includes('Minified React error') || err.message.includes('Hydration')) {
204+
return false
205+
}
206+
return true
207+
})
208+
209+
after(() => {
210+
if (!Cypress.env('KEEP_WORKSPACES')) {
211+
const token = Cypress.env('TEST_TOKEN')
212+
cy.request({
213+
method: 'DELETE',
214+
url: `/api/projects/${workspaceName}`,
215+
headers: { Authorization: `Bearer ${token}` },
216+
failOnStatusCode: false,
217+
})
218+
}
219+
})
220+
221+
it('demonstrates <feature>', () => {
222+
// ... single continuous test for one video file ...
223+
})
224+
})
225+
```
226+
227+
### 4. Key patterns to follow
228+
229+
| Pattern | Rule |
230+
|---------|------|
231+
| **Dual layout** | Session page renders desktop + mobile. Always use `.first()` on element queries that match both |
232+
| **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 |
233+
| **Workspace setup** | Create workspace → poll `/api/projects/:name` until 200 → configure runner-secrets if API key needed |
234+
| **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 |
235+
| **Operator pull policy** | For kind clusters, set `IMAGE_PULL_POLICY=IfNotPresent` on the operator to avoid re-pulling the 879MB runner image every session |
236+
| **File attachment** | Use `cy.get('input[type="file"]').first().selectFile({...}, { force: true })` with a `Cypress.Buffer` — no real file needed |
237+
| **Caption position** | Always `top:0` — bottom position obscures the chat toolbar |
238+
| **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 |
239+
| **Video output** | `e2e/cypress/videos/<name>.cy.ts.mp4` at 2560x1440 (Retina) |
240+
241+
### 5. Run the demo
242+
243+
```bash
244+
cd e2e
245+
npx cypress run --no-runner-ui --spec "cypress/e2e/<name>-demo.cy.ts"
246+
```
247+
248+
- Verify the video plays at human-readable speed
249+
- Check that captions don't overlap important UI elements
250+
- Re-run and iterate if needed — adjust timing or add/remove steps
251+
252+
### 6. Commit and push
253+
254+
- Commit the demo test file and any config changes (`cypress.config.ts`)
255+
- Push to the current branch
256+
- If a PR exists, note the demo in the PR description
257+
258+
## Reference implementation
259+
260+
See `e2e/cypress/e2e/chatbox-demo.cy.ts` for a complete working example that demonstrates:
261+
- Workspace creation, session creation
262+
- WelcomeExperience (streaming text, workflow cards)
263+
- Workflow selection ("Fix a bug") with Running state wait
264+
- File attachments (AttachmentPreview)
265+
- Autocomplete popovers (@agents, /commands) with real workflow data
266+
- Message queueing (QueuedMessageBubble)
267+
- Message history and queued message editing
268+
- Settings dropdown
269+
- Breadcrumb navigation
270+
271+
## Config requirements
272+
273+
`e2e/cypress.config.ts` must load `.env.test` and wire `TEST_TOKEN`:
274+
275+
```typescript
276+
// Load env files: .env.local > .env > .env.test
277+
const envFiles = ['.env.local', '.env', '.env.test'].map(f => path.resolve(__dirname, f))
278+
for (const envFile of envFiles) {
279+
if (fs.existsSync(envFile)) { dotenv.config({ path: envFile }) }
280+
}
281+
282+
// In setupNodeEvents:
283+
config.env.TEST_TOKEN = process.env.CYPRESS_TEST_TOKEN || process.env.TEST_TOKEN || config.env.TEST_TOKEN || ''
284+
config.env.ANTHROPIC_API_KEY = process.env.CYPRESS_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''
285+
```

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ celerybeat-schedule
8181
# Environments
8282
.env
8383
.env.uat
84+
.dev-bootstrap.env
8485
.venv
8586
env/
8687
venv/

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
.PHONY: local-dev-token
44
.PHONY: local-logs local-logs-backend local-logs-frontend local-logs-operator local-shell local-shell-frontend
55
.PHONY: local-test local-test-dev local-test-quick test-all local-url local-troubleshoot local-port-forward local-stop-port-forward
6-
.PHONY: push-all registry-login setup-hooks remove-hooks check-minikube check-kind check-kubectl
6+
.PHONY: push-all registry-login setup-hooks remove-hooks check-minikube check-kind check-kubectl dev-bootstrap
77
.PHONY: e2e-test e2e-setup e2e-clean deploy-langfuse-openshift
88
.PHONY: setup-minio minio-console minio-logs minio-status
99
.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
593593
GOOGLE_APPLICATION_CREDENTIALS="$(GOOGLE_APPLICATION_CREDENTIALS)" \
594594
./scripts/setup-vertex-kind.sh; \
595595
fi
596+
@if [ -f .dev-bootstrap.env ]; then \
597+
echo "$(COLOR_BLUE)$(COLOR_RESET) Bootstrapping developer workspace..."; \
598+
./scripts/bootstrap-workspace.sh || \
599+
echo "$(COLOR_YELLOW)$(COLOR_RESET) Bootstrap failed (non-fatal). Run 'make dev-bootstrap' manually."; \
600+
fi
596601
@echo ""
597602
@echo "$(COLOR_BOLD)Access the platform:$(COLOR_RESET)"
598603
@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
624629
(kubectl port-forward -n ambient-code svc/backend-service 8081:8080 >/dev/null 2>&1 &); \
625630
wait
626631

632+
dev-bootstrap: check-kubectl ## Bootstrap developer workspace with API key and integrations
633+
@./scripts/bootstrap-workspace.sh
634+
627635
##@ E2E Testing (Portable)
628636

629637
test-e2e: ## Run e2e tests against current CYPRESS_BASE_URL

components/frontend/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/** @type {import('next').NextConfig} */
22
const nextConfig = {
33
output: 'standalone',
4+
turbopack: {
5+
root: __dirname, // Silence "inferred workspace root" warning in monorepo
6+
},
47
experimental: {
58
instrumentationHook: true,
69
}

0 commit comments

Comments
 (0)