@hanzlaa/rcode 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +7 -1
  2. package/server/dashboard.js +105 -3
  3. package/server/lib/html/client/agents-data.js +27 -0
  4. package/server/lib/html/client/app.js +15 -0
  5. package/server/lib/html/client/components/App.js +211 -0
  6. package/server/lib/html/client/components/OrchPanel.js +293 -0
  7. package/server/lib/html/client/components/Sidebar.js +73 -0
  8. package/server/lib/html/client/components/Topbar.js +53 -0
  9. package/server/lib/html/client/components/XtermPanel.js +220 -0
  10. package/server/lib/html/client/components/shared.js +330 -0
  11. package/server/lib/html/client/icons-client.js +85 -0
  12. package/server/lib/html/client/orchestrator.js +279 -0
  13. package/server/lib/html/client/preact.js +34 -0
  14. package/server/lib/html/client/store.js +91 -0
  15. package/server/lib/html/client/util.js +186 -0
  16. package/server/lib/html/client/views/AgentsView.js +83 -0
  17. package/server/lib/html/client/views/DecisionsView.js +102 -0
  18. package/server/lib/html/client/views/FilesView.js +223 -0
  19. package/server/lib/html/client/views/KanbanView.js +236 -0
  20. package/server/lib/html/client/views/MemoryView.js +157 -0
  21. package/server/lib/html/client/views/MilestonesView.js +136 -0
  22. package/server/lib/html/client/views/OrchestrationView.js +167 -0
  23. package/server/lib/html/client/views/OverviewView.js +221 -0
  24. package/server/lib/html/client/views/PhasesView.js +184 -0
  25. package/server/lib/html/client/views/RoadmapView.js +238 -0
  26. package/server/lib/html/client/views/SprintsView.js +178 -0
  27. package/server/lib/html/client/views/TasksView.js +148 -0
  28. package/server/lib/html/client.js +41 -1775
  29. package/server/lib/html/css.js +264 -44
  30. package/server/lib/html/icons.js +68 -0
  31. package/server/lib/html/shell.js +9 -296
  32. package/server/lib/scanner.js +76 -0
  33. package/server/orchestrator.js +237 -313
@@ -0,0 +1,102 @@
1
+ /**
2
+ * DecisionsView — Preact component.
3
+ *
4
+ * Ports renderDecisions() from client-main.js to a component.
5
+ * Filtering is local component state (useState), not the DOM filterItems hack.
6
+ * Reads decisions from the store via useStore().
7
+ */
8
+
9
+ import { html, useState } from '../preact.js';
10
+ import { useStore } from '../store.js';
11
+ import { humanDate } from '../util.js';
12
+ import { CmdHint, CmdHints, showToast } from '../components/shared.js';
13
+
14
+ const CMD_HINTS = [
15
+ ['/rihal-council', 'Convene the council for a new decision'],
16
+ ['/rihal-discuss [agent] "topic"', 'Discuss with a specific expert'],
17
+ ['/rihal-decisions', 'View decision log'],
18
+ ];
19
+
20
+ export function DecisionsView() {
21
+ const S = useStore();
22
+ const decisions = S.decisions || [];
23
+
24
+ // Filter state — replaces the DOM filterItems hack
25
+ const [query, setQuery] = useState('');
26
+ const q = query.toLowerCase().trim();
27
+
28
+ if (!decisions.length) {
29
+ return html`
30
+ <div id="view-decisions" class="view active">
31
+ <div class="view-title">Decisions (ADRs)</div>
32
+ <div class="empty">
33
+ No decisions recorded yet.
34
+ <div class="empty-action">Decisions made during /rihal-council appear here</div>
35
+ </div>
36
+ </div>
37
+ `;
38
+ }
39
+
40
+ // Group by phase — same as client-main.js:renderDecisions
41
+ const grouped = {};
42
+ for (const d of decisions) {
43
+ const phase = (typeof d === 'object' ? d.phase : null) || 'General';
44
+ if (!grouped[phase]) grouped[phase] = [];
45
+ grouped[phase].push(d);
46
+ }
47
+
48
+ return html`
49
+ <div id="view-decisions" class="view active">
50
+ <div class="view-title">Decisions (ADRs)</div>
51
+ <div class="filter-bar">
52
+ <input
53
+ class="filter-input"
54
+ type="text"
55
+ placeholder="Filter…"
56
+ value=${query}
57
+ onInput=${e => setQuery(e.target.value)}
58
+ />
59
+ </div>
60
+ <div id="decisions-inner">
61
+ ${Object.entries(grouped).map(([phase, decs]) => {
62
+ const filteredDecs = q
63
+ ? decs.filter(d => {
64
+ const title = typeof d === 'string' ? d : (d.title || d.summary || d.decision || JSON.stringify(d).slice(0, 80));
65
+ return String(title).toLowerCase().includes(q);
66
+ })
67
+ : decs;
68
+ if (!filteredDecs.length) return null;
69
+ return html`
70
+ <div key=${phase}>
71
+ <div class="memory-group-header">${phase}</div>
72
+ <div class="decision-list">
73
+ ${filteredDecs.map((d, i) => {
74
+ const title = typeof d === 'string'
75
+ ? d
76
+ : (d.title || d.summary || d.decision || JSON.stringify(d).slice(0, 80));
77
+ const dateInfo = (typeof d === 'object' && d.date)
78
+ ? html`<span style="color:var(--text-muted);font-size:var(--text-xs);margin-left:8px;">${humanDate(d.date)}</span>`
79
+ : null;
80
+ const phaseInfo = (typeof d === 'object' && d.phase)
81
+ ? html`<span class="tag">Phase ${d.phase}</span>`
82
+ : null;
83
+ const rationale = (typeof d === 'object' && d.rationale)
84
+ ? html`<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">${d.rationale}</div>`
85
+ : null;
86
+ return html`
87
+ <div key=${i} class="item">
88
+ <div class="item-title">${title}${dateInfo}</div>
89
+ <div class="item-meta">${phaseInfo}</div>
90
+ ${rationale}
91
+ </div>
92
+ `;
93
+ })}
94
+ </div>
95
+ </div>
96
+ `;
97
+ })}
98
+ </div>
99
+ <${CmdHints} hints=${CMD_HINTS}/>
100
+ </div>
101
+ `;
102
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * FilesView — Preact port of the Files view from client-main.js.
3
+ *
4
+ * On mount: fetches /api/files to build the grouped file tree.
5
+ * Clicking a file: fetches /api/file?path=... and renders markdown via the
6
+ * global `marked` CDN lib (stays a CDN global — unchanged from legacy).
7
+ *
8
+ * Agent-jump bridge: when the store field `requestedFile` is set (by
9
+ * AgentsView), FilesView picks it up and pre-fills the search filter, then
10
+ * clears the field so subsequent renders don't re-trigger.
11
+ */
12
+
13
+ import { html, useState, useEffect, useCallback } from '../preact.js';
14
+ import { useStore, setState } from '../store.js';
15
+ import { showToast } from '../components/shared.js';
16
+
17
+ // ---- Markdown helpers (ported from client-main.js:287-294) ----
18
+ function stripFrontmatter(md) {
19
+ if (!md.startsWith('---')) return md;
20
+ const end = md.indexOf('\n---', 3);
21
+ return end === -1 ? md : md.slice(end + 4).trimStart();
22
+ }
23
+
24
+ function renderMd(md) {
25
+ const clean = stripFrontmatter(md);
26
+ return (typeof marked !== 'undefined')
27
+ ? marked.parse(clean)
28
+ : '<pre>' + clean.replace(/</g, '&lt;') + '</pre>';
29
+ }
30
+
31
+ // ---- File tree components ----
32
+ function FileEntry({ file, extraText, onSelect, isSelected }) {
33
+ return html`
34
+ <div
35
+ class=${'item item-clickable inline-file-entry' + (isSelected ? ' selected' : '')}
36
+ data-path=${file.path}
37
+ onClick=${() => onSelect(file)}
38
+ style="padding:var(--space-2) var(--space-3);font-family:'SF Mono',Monaco,Consolas,monospace;font-size:var(--text-xs);"
39
+ >
40
+ ${file.label}
41
+ </div>
42
+ `;
43
+ }
44
+
45
+ function FileGroup({ group, onSelect, selectedPath, filter }) {
46
+ function matchesFilter(file, extra) {
47
+ if (!filter) return true;
48
+ const q = filter.toLowerCase();
49
+ return (file.label + ' ' + file.path + (extra ? ' ' + extra : '')).toLowerCase().includes(q);
50
+ }
51
+
52
+ if (group.subGroups) {
53
+ return html`
54
+ <div class="inline-file-group" style="margin-bottom:var(--space-3);">
55
+ <div style="font-size:var(--text-xs);font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.07em;padding:var(--space-1) var(--space-3);">
56
+ ${group.group}
57
+ </div>
58
+ ${group.subGroups.map(sg => {
59
+ const visible = sg.files.filter(f => matchesFilter(f, sg.subGroup));
60
+ if (!visible.length) return null;
61
+ return html`
62
+ <details class="inline-subgroup" open style="margin-left:var(--space-2);margin-bottom:var(--space-1);">
63
+ <summary style="font-size:var(--text-xs);font-weight:500;color:var(--text-secondary);cursor:pointer;padding:var(--space-1) var(--space-3);user-select:none;">
64
+ ${sg.subGroup} <span style="color:var(--text-muted);font-weight:400;">(${visible.length})</span>
65
+ </summary>
66
+ ${visible.map(f => html`
67
+ <${FileEntry}
68
+ key=${f.path}
69
+ file=${f}
70
+ extraText=${sg.subGroup}
71
+ onSelect=${onSelect}
72
+ isSelected=${selectedPath === f.path}
73
+ />
74
+ `)}
75
+ </details>
76
+ `;
77
+ })}
78
+ </div>
79
+ `;
80
+ }
81
+
82
+ if (group.files) {
83
+ const visible = group.files.filter(f => matchesFilter(f, ''));
84
+ if (!visible.length) return null;
85
+ return html`
86
+ <div class="inline-file-group" style="margin-bottom:var(--space-3);">
87
+ <div style="font-size:var(--text-xs);font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.07em;padding:var(--space-1) var(--space-3);">
88
+ ${group.group}
89
+ </div>
90
+ ${visible.map(f => html`
91
+ <${FileEntry}
92
+ key=${f.path}
93
+ file=${f}
94
+ onSelect=${onSelect}
95
+ isSelected=${selectedPath === f.path}
96
+ />
97
+ `)}
98
+ </div>
99
+ `;
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ // ---- File content pane ----
106
+ function FileContent({ path, html: htmlContent, loading, error }) {
107
+ if (loading) {
108
+ return html`
109
+ <div id="file-view">
110
+ <div class="skeleton"></div>
111
+ <div class="skeleton" style="height:200px;"></div>
112
+ </div>
113
+ `;
114
+ }
115
+ if (error) {
116
+ return html`
117
+ <div id="file-view">
118
+ <div style="color:var(--accent-red);padding:16px;">${error}</div>
119
+ </div>
120
+ `;
121
+ }
122
+ if (!path || !htmlContent) return html`<div id="file-view"></div>`;
123
+
124
+ function copyPath() {
125
+ navigator.clipboard.writeText(path).then(() => {
126
+ showToast('Path copied!');
127
+ }).catch(() => {});
128
+ }
129
+
130
+ return html`
131
+ <div id="file-view">
132
+ <div class="file-path-header">
133
+ <span>${path}</span>
134
+ <button class="copy-btn" onClick=${copyPath}>Copy</button>
135
+ </div>
136
+ <div class="md-render" dangerouslySetInnerHTML=${{ __html: htmlContent }} />
137
+ </div>
138
+ `;
139
+ }
140
+
141
+ // ---- Root FilesView ----
142
+ export function FilesView() {
143
+ const { requestedFile } = useStore();
144
+
145
+ const [groups, setGroups] = useState([]);
146
+ const [loading, setLoading] = useState(true);
147
+ const [filter, setFilter] = useState('');
148
+ const [selectedPath, setSelectedPath] = useState(null);
149
+ const [fileContent, setFileContent] = useState({ html: null, loading: false, error: null });
150
+
151
+ // Fetch file tree on mount
152
+ useEffect(() => {
153
+ setLoading(true);
154
+ fetch('/api/files')
155
+ .then(r => r.json())
156
+ .then(data => { setGroups(Array.isArray(data) ? data : []); })
157
+ .catch(() => setGroups([]))
158
+ .finally(() => setLoading(false));
159
+ }, []);
160
+
161
+ // Agent-jump bridge: requestedFile set by AgentsView
162
+ useEffect(() => {
163
+ if (!requestedFile) return;
164
+ setFilter(requestedFile);
165
+ // Clear the bridge field so this doesn't re-trigger
166
+ setState({ requestedFile: null });
167
+ }, [requestedFile]);
168
+
169
+ const loadFile = useCallback(async (file) => {
170
+ setSelectedPath(file.path);
171
+ setFileContent({ html: null, loading: true, error: null });
172
+ try {
173
+ const resp = await fetch('/api/file?path=' + encodeURIComponent(file.path));
174
+ if (!resp.ok) {
175
+ setFileContent({ html: null, loading: false, error: 'Failed to load file.' });
176
+ return;
177
+ }
178
+ const text = await resp.text();
179
+ setFileContent({ html: renderMd(text), loading: false, error: null });
180
+ } catch {
181
+ setFileContent({ html: null, loading: false, error: 'Network error.' });
182
+ }
183
+ }, []);
184
+
185
+ return html`
186
+ <div class="view active" id="view-files">
187
+ <div class="view-title">Files</div>
188
+ <div id="file-list-inline">
189
+ <div class="filter-bar">
190
+ <input
191
+ class="filter-input"
192
+ type="text"
193
+ placeholder="Search files…"
194
+ value=${filter}
195
+ onInput=${e => setFilter(e.target.value)}
196
+ />
197
+ </div>
198
+ <div id="inline-file-items" class="phase-list">
199
+ ${loading
200
+ ? html`<div class="empty" style="margin:16px;">Loading…</div>`
201
+ : groups.length === 0
202
+ ? html`<div class="empty" style="margin:16px;">No files found.</div>`
203
+ : groups.map(g => html`
204
+ <${FileGroup}
205
+ key=${g.group}
206
+ group=${g}
207
+ onSelect=${loadFile}
208
+ selectedPath=${selectedPath}
209
+ filter=${filter}
210
+ />
211
+ `)
212
+ }
213
+ </div>
214
+ </div>
215
+ <${FileContent}
216
+ path=${selectedPath}
217
+ html=${fileContent.html}
218
+ loading=${fileContent.loading}
219
+ error=${fileContent.error}
220
+ />
221
+ </div>
222
+ `;
223
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * KanbanView — Preact port of renderKanban() from client-kanban.js.
3
+ *
4
+ * Displays a 4-column board (todo / in_progress / blocked / done) with
5
+ * draggable cards. Drag-and-drop is visual-only (not persisted).
6
+ *
7
+ * Run/Stop/View card buttons call imported orchestrator functions (Sprint 31.4).
8
+ */
9
+
10
+ import { html, useState, useCallback } from '../preact.js';
11
+ import { useStore } from '../store.js';
12
+ import { allTasks } from '../util.js';
13
+ import { runStory, stopStory, openOrchPanel } from '../orchestrator.js';
14
+ import { showToast } from '../components/shared.js';
15
+
16
+ // ---- Column descriptors ----
17
+ const COLS = [
18
+ { id: 'todo', label: 'Todo', cssClass: 'col-todo' },
19
+ { id: 'in_progress', label: 'In Progress', cssClass: 'col-prog' },
20
+ { id: 'blocked', label: 'Blocked', cssClass: 'col-blocked' },
21
+ { id: 'done', label: 'Done', cssClass: 'col-done' },
22
+ ];
23
+
24
+ /** Map a stored task status to a canonical column id. */
25
+ function kanbanCol(status) {
26
+ if (status === 'done' || status === 'completed') return 'done';
27
+ if (status === 'in_progress' || status === 'active' || status === 'running') return 'in_progress';
28
+ if (status === 'blocked') return 'blocked';
29
+ return 'todo';
30
+ }
31
+
32
+ /** Return the effective column, hoisting to in_progress if a live session exists. */
33
+ function effCol(task, activeSessions) {
34
+ const running = Array.isArray(activeSessions)
35
+ ? activeSessions.some(s => s.storyId === task.id && s.status === 'running')
36
+ : false;
37
+ return (task.id && running) ? 'in_progress' : kanbanCol(task.status);
38
+ }
39
+
40
+ // ---- Card component ----
41
+ function KanbanCard({ task, col, onDragStart, onDragEnd }) {
42
+ const sid = task.id || '';
43
+ const c = col;
44
+ const isRunning = c === 'in_progress';
45
+ const canRun = c === 'todo' || c === 'blocked';
46
+ const pts = task.points ? task.points + 'p' : null;
47
+ const phase = task.phaseId ? 'P' + task.phaseId : null;
48
+ const sprintMeta = [pts, phase].filter(Boolean).join(' · ');
49
+
50
+ function handleRun(e) {
51
+ e.stopPropagation();
52
+ runStory(sid);
53
+ }
54
+ function handleStop(e) {
55
+ e.stopPropagation();
56
+ stopStory(sid);
57
+ }
58
+ function handleView(e) {
59
+ e.stopPropagation();
60
+ openOrchPanel(sid);
61
+ }
62
+
63
+ return html`
64
+ <div
65
+ class=${'kanban-card s-' + c + (isRunning ? ' running' : '')}
66
+ data-story-id=${sid}
67
+ draggable=${!!sid}
68
+ onDragStart=${sid ? onDragStart : undefined}
69
+ onDragEnd=${sid ? onDragEnd : undefined}
70
+ >
71
+ <div class="kanban-card-header">
72
+ <div class="kanban-card-title">${task.title || task.id || 'Untitled'}</div>
73
+ ${sid ? html`<div class="kanban-card-id">${sid.slice(0, 8)}</div>` : null}
74
+ </div>
75
+ ${sprintMeta ? html`
76
+ <div class="kanban-card-meta">
77
+ <span class="kanban-card-sprint">${sprintMeta}</span>
78
+ <span class="kanban-card-status">${COLS.find(co => co.id === c)?.label || c}</span>
79
+ </div>
80
+ ` : null}
81
+ ${isRunning ? html`
82
+ <div class="card-run-indicator" id=${'run-ind-' + sid}>
83
+ <span class="run-pulse"></span>running
84
+ </div>
85
+ ` : null}
86
+ ${sid ? html`
87
+ <div class="kanban-card-actions">
88
+ ${canRun ? html`
89
+ <button class="kanban-run-btn" onClick=${handleRun}>▶ Run</button>
90
+ ` : isRunning ? html`
91
+ <button class="kanban-stop-btn" onClick=${handleStop}>■ Stop</button>
92
+ <button class="kanban-view-btn" onClick=${handleView}>↗ View</button>
93
+ ` : html`
94
+ <button class="kanban-view-btn" onClick=${handleView}>↗ Logs</button>
95
+ `}
96
+ </div>
97
+ ` : null}
98
+ </div>
99
+ `;
100
+ }
101
+
102
+ // ---- Column component ----
103
+ function KanbanColumn({ col, cards, onDragStart, onDragEnd, onDragOver, onDrop }) {
104
+ return html`
105
+ <div class=${'kanban-col ' + col.cssClass} data-col=${col.id}>
106
+ <div class="kanban-col-head">
107
+ <span class="col-label">
108
+ <span class="col-status-dot"></span>
109
+ ${col.label}
110
+ </span>
111
+ <span class="kanban-count">${cards.length}</span>
112
+ </div>
113
+ <div
114
+ class="kanban-col-body"
115
+ onDragOver=${e => { e.preventDefault(); onDragOver(e, col.id); }}
116
+ onDrop=${e => { e.preventDefault(); onDrop(e, col.id); }}
117
+ >
118
+ ${cards.map(t => html`
119
+ <${KanbanCard}
120
+ key=${t.id || t.title}
121
+ task=${t}
122
+ col=${col.id}
123
+ onDragStart=${e => onDragStart(e, t)}
124
+ onDragEnd=${onDragEnd}
125
+ />
126
+ `)}
127
+ </div>
128
+ </div>
129
+ `;
130
+ }
131
+
132
+ // ---- Root KanbanView ----
133
+ export function KanbanView() {
134
+ const { phases, activeSessions } = useStore();
135
+ const tasks = allTasks(phases);
136
+
137
+ // ---- Local column state (visual DnD overrides) ----
138
+ // Map<taskId, colId> — overrides the store-derived column for visual-only moves.
139
+ const [visualMoves, setVisualMoves] = useState({});
140
+ const [dragging, setDragging] = useState(null); // task being dragged
141
+
142
+ function getColFor(task) {
143
+ if (visualMoves[task.id]) return visualMoves[task.id];
144
+ return effCol(task, activeSessions);
145
+ }
146
+
147
+ // Build buckets
148
+ const buckets = { todo: [], in_progress: [], blocked: [], done: [] };
149
+ for (const t of tasks) {
150
+ const c = getColFor(t);
151
+ if (buckets[c]) buckets[c].push(t);
152
+ else buckets.todo.push(t);
153
+ }
154
+
155
+ // ---- DnD handlers ----
156
+ function handleDragStart(e, task) {
157
+ if (e.target.tagName === 'BUTTON') { e.preventDefault(); return; }
158
+ setDragging(task);
159
+ e.dataTransfer.effectAllowed = 'move';
160
+ if (e.currentTarget) e.currentTarget.style.opacity = '0.5';
161
+ }
162
+
163
+ function handleDragEnd(e) {
164
+ if (e.currentTarget) e.currentTarget.style.opacity = '';
165
+ setDragging(null);
166
+ }
167
+
168
+ function handleDragOver(e, colId) {
169
+ e.preventDefault();
170
+ }
171
+
172
+ function handleDrop(e, colId) {
173
+ if (!dragging || !dragging.id) return;
174
+ setVisualMoves(prev => ({ ...prev, [dragging.id]: colId }));
175
+ setDragging(null);
176
+ showToast('Moved (visual only — not persisted)'); // visual only — not persisted
177
+ }
178
+
179
+ // ---- Manual refresh ----
180
+ function handleSync() {
181
+ if (typeof window._preactRefresh === 'function') window._preactRefresh();
182
+ }
183
+
184
+ function handleSessions() {
185
+ window.location.hash = 'orchestration';
186
+ }
187
+
188
+ if (!tasks.length) {
189
+ return html`
190
+ <div class="view active" id="view-kanban">
191
+ <div class="kanban-topbar">
192
+ <div class="kanban-topbar-title">
193
+ <span class="orch-status-dot" id="orch-dot"></span>
194
+ Kanban
195
+ </div>
196
+ <div class="kanban-topbar-actions">
197
+ <button class="kanban-refresh-btn" onClick=${handleSync}>⟳ Sync</button>
198
+ <button class="kanban-refresh-btn" style="margin-left:4px;" onClick=${handleSessions}>⊞ Sessions</button>
199
+ </div>
200
+ </div>
201
+ <div class="empty" style="margin:24px;">
202
+ No stories yet.
203
+ <div class="empty-action">/rihal-plan to generate tasks</div>
204
+ </div>
205
+ </div>
206
+ `;
207
+ }
208
+
209
+ return html`
210
+ <div class="view active" id="view-kanban">
211
+ <div class="kanban-topbar">
212
+ <div class="kanban-topbar-title">
213
+ <span class="orch-status-dot" id="orch-dot"></span>
214
+ Kanban
215
+ </div>
216
+ <div class="kanban-topbar-actions">
217
+ <button class="kanban-refresh-btn" onClick=${handleSync}>⟳ Sync</button>
218
+ <button class="kanban-refresh-btn" style="margin-left:4px;" onClick=${handleSessions}>⊞ Sessions</button>
219
+ </div>
220
+ </div>
221
+ <div class="kanban-board">
222
+ ${COLS.map(col => html`
223
+ <${KanbanColumn}
224
+ key=${col.id}
225
+ col=${col}
226
+ cards=${buckets[col.id] || []}
227
+ onDragStart=${handleDragStart}
228
+ onDragEnd=${handleDragEnd}
229
+ onDragOver=${handleDragOver}
230
+ onDrop=${handleDrop}
231
+ />
232
+ `)}
233
+ </div>
234
+ </div>
235
+ `;
236
+ }