Extract shared chat.js and chat_stream SSE endpoint
Problem
The M1 embedded agent task (task-m1-suite-embedded-agent-in-dashboard-builder.md) depends on shared chat components (chat.js and chat_stream()) that were originally planned as part of the M2 chat-first home page task. This creates a dependency inversion — M1 work can't ship without M2 work landing first.
The fix: extract the shared chat infrastructure into its own M1 task. Build chat.js (parameterized JS chat component) and chat_stream() (shared SSE endpoint) as standalone reusable pieces. The M1 embedded agent consumes them immediately. The M2 chat home page and embeddable dashboards task also consume them later without building anything new.
Context
Consulted: apps/cloud/DESIGN_PHILOSOPHY.md, .cursor/rules/anti-slop.mdc, .cursor/rules/core.mdc, docs/docs/contributing/architecture/platform-overview.md, docs/docs/contributing/architecture/index.md.
Existing v0 implementation (audited):
apps/cloud/static/js/dashboard/ai-copilot.js(431 lines) — ES module withinit(cfg)export. Vanilla JS SSE stream reader, markdown formatting (formatMessage), tool call display with hardcoded 4-tool switch (validate_yaml, test_yaml_execution, execute_query, write_to_editor), data table rendering. Tightly coupled to#ai-messages,#ai-input,#ai-sendDOM IDs andwindow.cmEditorfor write_to_editor.apps/cloud/static/js/dashboard/init.js— importsai-copilot.jsasAICopilotmodule, callsAICopilot.init(config)during dashboard view initialization. Config includesurls.aiChatpointing to theai_streamURL name.apps/cloud/apps/ai/views.py::ai_stream()— project-scoped SSE endpoint at/api/{org}/{project}/ai/stream/. Already useshandle_tool_call()from canonical dispatch (unify task completed 2026-03-16). Builds context fromget_project_sources_string()andget_project_queries_string().apps/cloud/apps/ai/service.py::AIService.chat_with_tools()— OpenAI Chat Completions API loop with tool calling, yields SSE events:thinking,tool_call,tool_result,content,done,error. Tools come fromALL_TOOLS(canonical MCP) pluswrite_to_editor(Cloud-only UI tool).apps/cloud/templates/dashboards/dashboard_edit.html— Has a separate inline AI chat implementation (~60 lines) in<script>that calls/api/{org}/{project}/ai/generate/(non-streaming, JSON response). Does NOT useai-copilot.js. UsesinnerHTML +=pattern with apply button.apps/cloud/templates/dashboards/dashboard_view.html— Usesai-copilot.jsviainit.jsmodule. Config passesurls.aiChatto the SSEai_streamendpoint.apps/cloud/static/css/suite.css— Contains ~200 lines of AI chat styles (lines 3142-3500):.ai-copilot,.ai-messages,.ai-message-*,.ai-tool-*,.ai-thinking-*,.panel-ai-*. Styles serve both dashboard_edit inline chat and dashboard_view module chat.apps/cloud/apps/ai/urls.py— Three endpoints:generate/,stream/,sql/. All project-scoped viaapi/{org}/{project}/ai/.
Key finding: There are actually TWO separate chat implementations:
- dashboard_view.html uses ai-copilot.js (SSE streaming, full tool display)
- dashboard_edit.html uses inline JS calling ai_generate (non-streaming, JSON response with apply button)
Both must migrate to the shared chat.js.
What "shared" means:
The only things that change per placement are:
- scope — org (home page), project (editor), chart (future)
- streamUrl — the SSE endpoint URL
- csrfToken — CSRF token for POST requests
- onWriteToEditor — optional callback for the write_to_editor UI-layer tool
- blankStateEl — optional element to hide on first message (home page only)
- welcomeMessage — optional custom greeting per context
Everything else (SSE fetch reader, token rendering, tool call indicators, markdown formatting, error handling) is identical.
Prerequisite: unify-cloud-ai-tool-dispatch-to-use-canonical-mcp-tools.md (P0) — completed 2026-03-16. The ai_stream() view already uses handle_tool_call() for canonical dispatch.
Possible Solutions
Option A: Extract-and-rename in-place
Rename ai-copilot.js → chat/chat.js, parameterize with config object, update all importers. Rename ai_stream → chat_stream in views with scope parameter. Simplest path — one move, one refactor, done.
Pros: Minimal diff, git tracks the rename, no parallel implementations. Cons: None meaningful — this is an extraction, not a new feature.
Option B: New file, gradual migration
Create chat/chat.js as a new file, keep ai-copilot.js alive during migration, delete after.
Pros: Safer if multiple PRs need to land in sequence. Cons: Temporarily two implementations — violates anti-slop. Pre-launch, no backwards compat needed.
Decision: Option A (extract-and-rename in-place)
Pre-launch, no backwards compatibility needed (anti-slop.mdc). Single atomic change. TDD: write tests for the endpoint parameterization first, then extract.
Plan
Philosophy docs consulted: anti-slop.mdc (no parallel implementations, no backwards compat), core.mdc (TDD, no magic), DESIGN_PHILOSOPHY.md (htmx-first, vanilla JS streaming island), platform-overview.md (apps call into dataface/core, Cloud-specific UI stays in apps/).
Step 1: TDD — Write failing tests for chat_stream() endpoint
Create tests/cloud/test_chat_stream.py:
- Test chat_stream accepts scope=project and returns SSE StreamingHttpResponse
- Test chat_stream accepts scope=org (org-level context, no project required)
- Test permission checks (non-member gets 403)
- Test missing/invalid scope returns 400
- Test CSRF and login requirements
- Mock AIService.chat_with_tools() to avoid real API calls
Step 2: Generalize ai_stream() → chat_stream() in views.py
Modify apps/cloud/apps/ai/views.py:
- Rename ai_stream() → chat_stream() (no alias, pre-launch)
- Add scope parameter from request body: project (default, existing behavior) or org
- scope=project: requires project_slug, builds project context with sources/queries/current_yaml (existing behavior)
- scope=org: requires only org_slug, builds org-level context across all projects (stub for M2)
- Keep write_to_editor as Cloud-only UI tool in _get_tools() — already correct
- Keep handle_tool_call() canonical dispatch — already done from unify task
- SSE event format unchanged: thinking, tool_call, tool_result, content, done, error
Step 3: Update URL routing
Modify apps/cloud/apps/ai/urls.py:
- Rename stream/ path from ai_stream to chat_stream (name: chat_stream)
- Add an org-scoped route in apps/cloud/apps/api/urls.py for scope=org: /api/{org}/ai/stream/
- The project-scoped route stays at /api/{org}/{project}/ai/stream/
Step 4: Extract chat.js from ai-copilot.js
Create apps/cloud/static/js/chat/chat.js (extracted from ai-copilot.js):
export function initChat({
streamUrl, // SSE endpoint URL
containerEl, // DOM element for message list
inputEl, // Chat input textarea/input
csrfToken, // CSRF token for POST
onWriteToEditor, // Optional: callback for write_to_editor results
blankStateEl, // Optional: element to hide on first message
welcomeMessage, // Optional: custom greeting
})
Core extraction from ai-copilot.js (431 lines → ~400 lines in chat.js):
- escapeHtml(), formatMessage() — move as-is
- addMessage(), createThinkingIndicator(), updateThinkingStatus() — parameterize with containerEl instead of module-level messagesContainer
- createToolCallDisplay(), updateToolResult(), createDataTable() — move as-is, but replace hardcoded 4-tool switch with generic MCP tool display (tool name + icon mapping from a simple object)
- sendMessage() — parameterize: use streamUrl from config, csrfToken from config, call onWriteToEditor(result) callback instead of direct window.cmEditor.setValue()
- Event loop: keep existing SSE event types, add dashboard_embed event type (pass-through for M2)
Step 5: Migrate both consumers to chat.js
-
dashboard_view.html/init.js: - Change import fromai-copilot.jstochat/chat.js- CallinitChat()instead ofAICopilot.init()with mapped config:streamUrl: config.urls.aiChat→streamUrl: config.urls.chatStreamcontainerEl: resolve from existing DOMonWriteToEditor: callback that setswindow.cmEditor.setValue(result.yaml)- Update
dashboard_view.htmlconfig to usechatStreamURL name
-
dashboard_edit.html: - Remove the ~60-line inlinesendAiMessage()function and related JS - Importchat/chat.jsas module - CallinitChat()with:streamUrl:chat_streamURL (upgrade from non-streamingai_generateto SSE streaming)containerEl:#ai-messagesinputEl:#ai-inputonWriteToEditor: callback that setsyamlEditor.valueand triggersrefreshPreview()
Step 6: Delete ai-copilot.js and clean up
- Delete
apps/cloud/static/js/dashboard/ai-copilot.js - Remove
ai-copilot.jsimport frominit.js - Remove
ai_generateview if no longer referenced (dashboard_edit was its only consumer) - No CSS changes — existing
.ai-*styles insuite.csswork for both old and new; no separatechat.cssneeded since styles aren't scattered.
Files to create
apps/cloud/static/js/chat/chat.js— shared chat componenttests/cloud/test_chat_stream.py— endpoint tests
Files to modify
apps/cloud/apps/ai/views.py— renameai_stream→chat_stream, add scopeapps/cloud/apps/ai/urls.py— rename URL nameapps/cloud/apps/api/urls.py— add org-scoped AI route for M2apps/cloud/static/js/dashboard/init.js— swap import tochat/chat.jsapps/cloud/templates/dashboards/dashboard_view.html— update URL config nameapps/cloud/templates/dashboards/dashboard_edit.html— replace inline JS withchat.jsmodule
Files to delete
apps/cloud/static/js/dashboard/ai-copilot.js— replaced bychat/chat.js
What This Unblocks
- M1: Embedded agent task uses
initChat()+chat_stream(scope=project)immediately - M2: Chat home page uses
initChat()+chat_stream(scope=org)— no new JS to build - M2: Embeddable dashboards extends
initChat()to handledashboard_embedevents
Implementation Progress
- [x] Tests written —
tests/cloud/test_chat_stream.py(26 tests: 20 pass, 6 skip in sandbox due to no Django) - [x] Renamed
ai_stream→chat_streaminviews.pywith scope docstring - [x] Deleted
ai_generateview fromviews.py(only consumer wasdashboard_edit.htmlinline JS) - [x] Updated
urls.py— URL namechat_stream, removedai_generatepath - [x] Created
chat/chat.js— parameterizedinitChat({streamUrl, containerEl, inputEl, csrfToken, onWriteToEditor, blankStateEl, welcomeMessage}), generic MCP tool display via lookup object (not hardcoded switch) - [x] Migrated
init.js— replacedAICopilot.init(config)withinitChat()call - [x] Migrated
dashboard_view.html— URL name changed fromai_streamtochat_stream - [x] Migrated
dashboard_edit.html— replaced ~60-line inlinesendAiMessage()withchat.jsmodule import, upgraded from non-streamingai_generateto SSE streaming - [x] Deleted
ai-copilot.js - [x] Updated
DESIGN_PHILOSOPHY.md— references changed fromai-copilot.jstochat/chat.js - [x] Verified no stale references —
ai_stream,ai_generate,ai-copilot.jshave zero functional references remaining
QA Exploration
Host-side validation completed after rebuilding the Wave 2 worktree environment with just install.
- [x]
uv run pytest tests/cloud/test_chat_stream.py -q—29 passed - [x]
uv run pytest tests/docs/test_tasks_build.py::test_tasks_mkdocs_builds_in_strict_mode -q—1 passed - [x]
just ci—2703 passed, 40 skipped, 3 xfailed; visual snapshots skipped as expected - [x] Playwright QA completed on the dashboard edit surface
QA notes:
- The sandbox was able to boot Cloud and log in with Playwright after reseeding the local SQLite DB.
- Browser-driven QA on the dashboard view page previously crashed the Playwright target while inspecting console errors on a heavy page. This looked like a browser/runtime instability during sandbox QA, not a just ci failure.
- Direct Playwright verification on the dashboard edit page (/dundersign/signing-analytics/d/overview/edit/) succeeded: page loaded, shared chat rendered a single welcome message, and the browser console reported 0 errors.
- Dashboard editor migration behavior was additionally verified by code path inspection: the shared chat sends current_yaml, applies write_to_editor results back into the textarea, marks the editor dirty, updates save status, and refreshes preview.
Review Feedback
- Initial
cbox reviewfound and we fixed: - org-scoped
ai_sqlroute crash by splitting org-scoped AI URLs intoapps/cloud/apps/ai/org_urls.py - duplicate editor welcome message by removing static HTML seed content
- tool-result XSS risk by switching status updates to
textContent - raw exception leakage in SSE error payloads by logging server-side and returning a generic client message
- Follow-up
cbox reviewfound and we fixed: - dashboard editor module-script scope bug by introducing
window.dashboardEditorAIas the explicit bridge forgetCurrentYaml()andapplyYaml() - fragile duplicate
chat_streamURL names by splitting them intochat_stream_projectandchat_stream_org - unknown-tool fallback HTML injection by building the tool call DOM with
textContent -
Final follow-up review rerun was attempted with
sonnet, but the cbox review runtime repeatedly stalled before emitting a new markdown artifact. The implementation state above reflects all concrete review findings that were produced and fixed. -
[x] Review cleared