diff --git a/docs/architecture.md b/docs/architecture.md index 3463132..ea2601a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,10 +105,13 @@ The UI is a **hash-routed** SPA with ES modules under `static/js/`: - `app.js` — routing and boot - `projects.js`, `sessions.js`, `search.js`, `export.js` — route handlers +- `render/registry.js` — **tool dispatch registry** for session UI: `TOOL_USE_RENDERERS` and `TOOL_RESULT_RENDERERS` map tool name / `result_type` → render function (one module per type under `render/tool_use/` and `render/tool_result/`). Parallels backend `utils/tool_dispatch.py` (backend uses ordered predicates; frontend uses direct key lookup + fallback). - `shared/markdown.js` — markdown + **DOMPurify** sanitization (do not render raw LLM HTML) - `shared/state.js`, `shared/utils.js`, `shared/theme.js` — shared UI state and helpers -No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`). +`sessions.js` keeps workspace/session orchestration and message bubbles; tool cards delegate to `render/registry.js`. + +No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`), including `static/js/render/registry.test.js` for registry wiring and renderer escaping. ## Continuous integration diff --git a/static/js/render/constants.js b/static/js/render/constants.js new file mode 100644 index 0000000..8950299 --- /dev/null +++ b/static/js/render/constants.js @@ -0,0 +1,5 @@ +/** + * Sentinel when tool.name or result_type is missing. + * Used for fallback routing only — do not add UNKNOWN_DISPATCH_KEY to TOOL_*_RENDERERS. + */ +export const UNKNOWN_DISPATCH_KEY = 'unknown'; diff --git a/static/js/render/registry.js b/static/js/render/registry.js new file mode 100644 index 0000000..893a8dd --- /dev/null +++ b/static/js/render/registry.js @@ -0,0 +1,84 @@ +import { renderBashUse } from './tool_use/bash.js'; +import { renderReadUse } from './tool_use/read.js'; +import { renderWriteUse } from './tool_use/write.js'; +import { renderEditUse } from './tool_use/edit.js'; +import { renderGlobUse } from './tool_use/glob.js'; +import { renderGrepUse } from './tool_use/grep.js'; +import { renderTaskUse } from './tool_use/task.js'; +import { renderTodoWriteUse } from './tool_use/todo_write.js'; +import { renderAskUserQuestionUse } from './tool_use/ask_user_question.js'; +import { renderWebFetchUse } from './tool_use/web_fetch.js'; +import { renderWebSearchUse } from './tool_use/web_search.js'; +import { renderToolUseFallback } from './tool_use/fallback.js'; +import { getToolSummary } from './tool_use/summary.js'; +import { UNKNOWN_DISPATCH_KEY } from './constants.js'; + +import { renderBashResult } from './tool_result/bash.js'; +import { renderFileReadResult } from './tool_result/file_read.js'; +import { renderFileEditResult } from './tool_result/file_edit.js'; +import { renderFileWriteResult } from './tool_result/file_write.js'; +import { renderGlobResult } from './tool_result/glob.js'; +import { renderGrepResult } from './tool_result/grep.js'; +import { renderWebSearchResult } from './tool_result/web_search.js'; +import { renderWebFetchResult } from './tool_result/web_fetch.js'; +import { renderTaskResult } from './tool_result/task.js'; +import { renderTodoWriteResult } from './tool_result/todo_write.js'; +import { renderUserInputResult } from './tool_result/user_input.js'; +import { renderPlanResult } from './tool_result/plan.js'; +import { renderToolResultFallback } from './tool_result/fallback.js'; +import { toolResultHasBody } from './tool_result/utils.js'; + +export { getToolSummary, toolResultHasBody }; + +export const TOOL_USE_RENDERERS = { + Bash: renderBashUse, + Read: renderReadUse, + Write: renderWriteUse, + Edit: renderEditUse, + Glob: renderGlobUse, + Grep: renderGrepUse, + Task: renderTaskUse, + TodoWrite: renderTodoWriteUse, + AskUserQuestion: renderAskUserQuestionUse, + WebFetch: renderWebFetchUse, + WebSearch: renderWebSearchUse, +}; + +export const TOOL_RESULT_RENDERERS = { + bash: renderBashResult, + file_read: renderFileReadResult, + file_edit: renderFileEditResult, + file_write: renderFileWriteResult, + glob: renderGlobResult, + grep: renderGrepResult, + web_search: renderWebSearchResult, + web_fetch: renderWebFetchResult, + task: renderTaskResult, + todo_write: renderTodoWriteResult, + user_input: renderUserInputResult, + plan: renderPlanResult, +}; + +function getToolUseRenderer(name) { + return Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, name) + ? TOOL_USE_RENDERERS[name] + : renderToolUseFallback; +} + +function getToolResultRenderer(resultType) { + return Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, resultType) + ? TOOL_RESULT_RENDERERS[resultType] + : renderToolResultFallback; +} + +export function renderToolUse(tool) { + if (!tool) return ''; + const name = tool.name || UNKNOWN_DISPATCH_KEY; + return getToolUseRenderer(name)(tool); +} + +export function renderToolResult(parsed) { + if (!parsed) return ''; + const rt = parsed.result_type || UNKNOWN_DISPATCH_KEY; + return getToolResultRenderer(rt)(parsed); +} diff --git a/static/js/render/registry.test.js b/static/js/render/registry.test.js new file mode 100644 index 0000000..432225e --- /dev/null +++ b/static/js/render/registry.test.js @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest'; +import { + TOOL_USE_RENDERERS, + TOOL_RESULT_RENDERERS, + renderToolUse, + renderToolResult, + getToolSummary, + toolResultHasBody, +} from './registry.js'; +import { UNKNOWN_DISPATCH_KEY } from './constants.js'; +import { renderWebFetchUse } from './tool_use/web_fetch.js'; + +const CORE_TOOL_USE = ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Task', 'TodoWrite', 'AskUserQuestion', 'WebFetch', 'WebSearch']; + +const CORE_TOOL_RESULT = [ + 'bash', + 'file_read', + 'file_edit', + 'file_write', + 'glob', + 'grep', + 'web_search', + 'web_fetch', + 'task', + 'todo_write', + 'user_input', + 'plan', +]; + +describe('TOOL_USE_RENDERERS', () => { + it('registers core tool names', () => { + for (const name of CORE_TOOL_USE) { + expect(TOOL_USE_RENDERERS[name], name).toBeTypeOf('function'); + } + }); + + it('does not register the unknown dispatch sentinel as a tool renderer', () => { + expect(Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, UNKNOWN_DISPATCH_KEY)).toBe(false); + }); + + it('renderBashUse escapes HTML in command', () => { + const html = renderToolUse({ + name: 'Bash', + input: { command: '' }, + }); + expect(html).not.toContain('