@growthub/cli 0.10.0 → 0.12.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 (28) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +307 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +372 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/receipts/route.js +47 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +664 -82
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +1371 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1383 -24
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +7 -21
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/ownership-panel.jsx +222 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/page.jsx +19 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +2 -1
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +116 -24
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +497 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +20 -4
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +19 -4
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -5
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +473 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +583 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +34 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +3 -1
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/export-training-traces.mjs +144 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/grade-raw-pairs.mjs +279 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/harvest-cursor-traces.mjs +288 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/upload-graded-traces.mjs +128 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +19 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/alignment-loop.config.json +264 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/workers/custom-workspace-operator/CLAUDE.md +38 -0
  27. package/dist/index.js +1416 -2627
  28. package/package.json +1 -1
@@ -0,0 +1,1371 @@
1
+ "use client";
2
+
3
+ /**
4
+ * HelperSidecar — workspace-native helper panel.
5
+ *
6
+ * Rendered as a fixed right-side sidecar. Slides in over the Data Model
7
+ * page without any route change. All Playwright data-* selectors are
8
+ * declared here; do not rename them without updating the test suite.
9
+ *
10
+ * Props:
11
+ * open boolean — controlled by page-level state
12
+ * onClose fn — called when sidecar should close
13
+ * workspaceConfig object — live config (for Setup tab: localModel, localEndpoint)
14
+ * initialIntent string — pre-set intent based on the object that triggered open
15
+ * initialPrompt string — optional starter prompt seeded into the textarea
16
+ * onApplied fn(cfg) — called with updated workspaceConfig after apply
17
+ */
18
+
19
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20
+ import ReactMarkdown from "react-markdown";
21
+ import remarkGfm from "remark-gfm";
22
+ import {
23
+ AlertCircle,
24
+ ArrowLeft,
25
+ ArrowUp,
26
+ ArrowUpRight,
27
+ Box,
28
+ CheckSquare,
29
+ ChevronDown,
30
+ ChevronRight,
31
+ Database,
32
+ HelpCircle,
33
+ LayoutDashboard,
34
+ ListPlus,
35
+ Paperclip,
36
+ Plug,
37
+ Plus,
38
+ Settings,
39
+ SquareDashedMousePointer,
40
+ SquarePen,
41
+ Wrench,
42
+ Wrench as RepairIcon,
43
+ X,
44
+ } from "lucide-react";
45
+
46
+ // Generic "Tool Call Output" title matches the reference grammar — the
47
+ // user already sees the prompt + assistant response in the chat above,
48
+ // so the tool-call card just needs a clean, neutral header that reads
49
+ // as a metadata accordion. The wrench icon is used for every type.
50
+
51
+ // Thin, agnostic tool-call card. One per applied proposal. Renders the
52
+ // success confirmation the user needs to close the no-code loop. The
53
+ // chevron accordion exposes raw payload JSON for inspection; the Open
54
+ // button navigates to the created artifact (via onOpenArtifact, owned
55
+ // by the page-level shell). Pure presentational — no state above an
56
+ // expanded/collapsed flag.
57
+ function ToolCallCard({ proposal, content, onOpenArtifact }) {
58
+ const [open, setOpen] = useState(false);
59
+ const canNavigate = typeof onOpenArtifact === "function" && resolveArtifactTarget(proposal) != null;
60
+ const meta = {
61
+ type: proposal?.type,
62
+ affectedField: proposal?.affectedField,
63
+ payload: proposal?.payload,
64
+ rationale: proposal?.rationale,
65
+ confidence: proposal?.confidence,
66
+ };
67
+ return (
68
+ <div className="dm-helper-toolcall" data-toolcall-type={proposal?.type}>
69
+ <button
70
+ type="button"
71
+ className="dm-helper-toolcall-row"
72
+ onClick={() => setOpen((v) => !v)}
73
+ aria-expanded={open}
74
+ aria-label={`${open ? "Hide" : "Show"} tool call output`}
75
+ >
76
+ <Wrench size={14} className="dm-helper-toolcall-icon" aria-hidden="true" />
77
+ <span className="dm-helper-toolcall-title">Tool Call Output</span>
78
+ <ChevronDown
79
+ size={14}
80
+ className={`dm-helper-toolcall-chevron${open ? " is-open" : ""}`}
81
+ aria-hidden="true"
82
+ />
83
+ </button>
84
+ {open && (
85
+ <div className="dm-helper-toolcall-body">
86
+ {content && <div className="dm-helper-toolcall-content">{content}</div>}
87
+ <pre className="dm-helper-toolcall-json">
88
+ {JSON.stringify(meta, null, 2)}
89
+ </pre>
90
+ </div>
91
+ )}
92
+ {canNavigate && (
93
+ <button
94
+ type="button"
95
+ className="dm-helper-toolcall-open"
96
+ onClick={() => onOpenArtifact(proposal)}
97
+ >
98
+ Open
99
+ <ArrowUpRight size={12} aria-hidden="true" />
100
+ </button>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ // Pair a system apply-receipt message with the actual proposal payload
107
+ // it confirms. The applyResult (rehydrated from row.lastApplied at thread
108
+ // load time) carries the typed payloads keyed in order — we walk the
109
+ // system messages in order and pop the matching applied entry. Falls
110
+ // back to a content-only render when no payload is available.
111
+ function resolveSystemReceipt(message, applyResult) {
112
+ if (!message || message.role !== "system") return null;
113
+ // Direct attachment (when the message itself carries proposal data).
114
+ if (message.proposal) return message.proposal;
115
+ if (message.payload) {
116
+ return { type: message.kind || "system", payload: message.payload };
117
+ }
118
+ // Pull from rehydrated applyResult.applied[] — first non-consumed entry.
119
+ const applied = applyResult && Array.isArray(applyResult.applied) ? applyResult.applied : [];
120
+ // Heuristic match: the system message content contains the proposal
121
+ // type when the apply receipt is "Applied N: <type> → <field>".
122
+ for (const a of applied) {
123
+ const p = a?.proposal || a;
124
+ if (!p?.type) continue;
125
+ if (typeof message.content === "string" && message.content.includes(p.type)) {
126
+ return p;
127
+ }
128
+ }
129
+ // Otherwise return the first applied as a best-effort.
130
+ if (applied.length > 0) return applied[0].proposal || applied[0];
131
+ return { type: message.kind || "apply-receipt", payload: null };
132
+ }
133
+
134
+ // Resolve where the Open button should navigate based on the proposal
135
+ // shape. Returns null when no navigation makes sense (e.g. explain.object).
136
+ function resolveArtifactTarget(proposal) {
137
+ const pl = proposal?.payload || {};
138
+ switch (proposal?.type) {
139
+ case "dataModel.object.create":
140
+ case "dataModel.object.update":
141
+ return pl.label || pl.id ? { surface: "data-model", source: pl.label || pl.id } : null;
142
+ case "dataModel.row.add":
143
+ return pl.objectId ? { surface: "data-model", source: pl.objectId } : null;
144
+ case "dashboard.create":
145
+ case "dashboard.update":
146
+ return pl.id ? { surface: "dashboard", dashboardId: pl.id } : null;
147
+ case "canvas.widget.add":
148
+ return pl.sourceObjectId ? { surface: "data-model", source: pl.sourceObjectId } : null;
149
+ case "repair.binding":
150
+ return pl.objectId ? { surface: "data-model", source: pl.objectId } : null;
151
+ default:
152
+ return null;
153
+ }
154
+ }
155
+
156
+ // Derive a short, human title for a thread row using the same fallback
157
+ // chain as the rail's chat tab (title → first summary clause → intent →
158
+ // generic label). Keeps the sidecar header title aligned with what the
159
+ // user sees in the rail thread list.
160
+ function deriveThreadDisplayTitle(threadOrRow, fallback = "Workspace Helper") {
161
+ if (!threadOrRow) return fallback;
162
+ const title = typeof threadOrRow.title === "string" ? threadOrRow.title.trim() : "";
163
+ if (title) return title;
164
+ const summary = typeof threadOrRow.summary === "string" ? threadOrRow.summary.trim() : "";
165
+ if (summary) {
166
+ const firstClause = summary.split(/[\n\.]/)[0].trim();
167
+ if (firstClause) return firstClause.length > 56 ? `${firstClause.slice(0, 55)}…` : firstClause;
168
+ }
169
+ return fallback;
170
+ }
171
+
172
+ // Lucide-react icon per intent. Used by the empty-state chip stack to give
173
+ // each governance lane a recognizable mark matching the Twenty/Ask AI
174
+ // reference grammar (icon-left, plain label).
175
+ const INTENT_ICON = {
176
+ build_dashboard: LayoutDashboard,
177
+ create_object: Plus,
178
+ edit_view: SquarePen,
179
+ repair: Wrench,
180
+ create_widget: SquareDashedMousePointer,
181
+ register_api: Plug,
182
+ explain: HelpCircle,
183
+ };
184
+
185
+ const HELPER_INTENTS = [
186
+ { value: "build_dashboard", label: "Build dashboard" },
187
+ { value: "create_widget", label: "Create widget" },
188
+ { value: "register_api", label: "Register API" },
189
+ { value: "create_object", label: "Create object" },
190
+ { value: "edit_view", label: "Edit view" },
191
+ { value: "repair", label: "Repair workspace" },
192
+ { value: "explain", label: "Explain object" },
193
+ ];
194
+
195
+ // 4 primary + 3 in the "More" dropdown — chosen by no-code usage frequency.
196
+ const PRIMARY_INTENT_VALUES = ["build_dashboard", "create_object", "edit_view", "repair"];
197
+ const MORE_INTENT_VALUES = ["create_widget", "register_api", "explain"];
198
+
199
+ // Quick-swap suggestions surfaced under the Local Model input in the Setup
200
+ // tab. Click → swaps the draft value; user still needs to hit Save & connect.
201
+ const SETUP_QUICK_MODELS = [
202
+ "gemma3:4b",
203
+ "llama3.1:8b",
204
+ "qwen2.5:7b",
205
+ "mistral:7b",
206
+ "phi3:14b",
207
+ ];
208
+
209
+ // Plain-language intent descriptions surfaced to no-code users.
210
+ const HELPER_INTENT_HINTS = {
211
+ build_dashboard: "Draft a dashboard with widgets you can review before applying.",
212
+ create_widget: "Suggest widgets that fit the data you already have.",
213
+ register_api: "Draft an API Registry entry with the fields needed to connect.",
214
+ create_object: "Translate a plain-language description into a new business object.",
215
+ edit_view: "Adjust an existing dashboard or layout — review the change before saving.",
216
+ repair: "Find missing references or broken bindings and propose the smallest fix.",
217
+ explain: "Explain what a workspace object is and how it is wired up.",
218
+ };
219
+
220
+ function intentLabel(value) {
221
+ const found = HELPER_INTENTS.find((i) => i.value === value);
222
+ return found ? found.label : value;
223
+ }
224
+
225
+ const MIN_WIDTH = 320;
226
+ const MAX_WIDTH_VW = 0.80;
227
+
228
+ let persistedWidth = 420;
229
+
230
+ function resolveSandboxEnvRow(workspaceConfig) {
231
+ const objects = workspaceConfig?.dataModel?.objects || [];
232
+ for (const obj of objects) {
233
+ if (obj.objectType === "sandbox-environment" && Array.isArray(obj.rows) && obj.rows.length > 0) {
234
+ return obj.rows[0];
235
+ }
236
+ }
237
+ return null;
238
+ }
239
+
240
+ // Render a tiny payload preview chip line so non-technical users see what
241
+ // will be changed without staring at raw JSON.
242
+ function summarizePayload(proposal) {
243
+ const p = proposal?.payload || {};
244
+ if (typeof p !== "object") return "";
245
+ switch (proposal.type) {
246
+ case "dashboard.create":
247
+ case "dashboard.update":
248
+ return p.name ? `name: ${p.name}` : (p.id ? `id: ${p.id}` : "");
249
+ case "widgetType.bind":
250
+ return p.kind ? `kind: ${p.kind}${p.label ? ` · label: ${p.label}` : ""}` : "";
251
+ case "canvas.widget.add":
252
+ return [p.kind ? `kind: ${p.kind}` : null, p.title ? `title: ${p.title}` : null].filter(Boolean).join(" · ");
253
+ case "canvas.tab.create":
254
+ return p.name ? `tab: ${p.name}` : "";
255
+ case "dataModel.object.create":
256
+ return [p.label ? `label: ${p.label}` : null, p.objectType ? `type: ${p.objectType}` : null, Array.isArray(p.columns) ? `${p.columns.length} fields` : null].filter(Boolean).join(" · ");
257
+ case "dataModel.object.update":
258
+ return p.id ? `id: ${p.id}` : "";
259
+ case "dataModel.row.add":
260
+ return p.objectId ? `into: ${p.objectId}` : "";
261
+ case "repair.binding":
262
+ return p.objectId ? `binding for: ${p.objectId}` : "";
263
+ case "explain.object":
264
+ return p.target ? `about: ${p.target}` : "informational";
265
+ default:
266
+ return "";
267
+ }
268
+ }
269
+
270
+ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, initialPrompt, initialThread, onApplied, onOpenArtifact }) {
271
+ const [activeTab, setActiveTab] = useState("assistant");
272
+ const [intent, setIntent] = useState(initialIntent || "create_object");
273
+ const [prompt, setPrompt] = useState(initialPrompt || "");
274
+ const [streaming, setStreaming] = useState(false);
275
+ const [streamBuffer, setStreamBuffer] = useState("");
276
+ const [result, setResult] = useState(null);
277
+ const [queryError, setQueryError] = useState("");
278
+ const [accepted, setAccepted] = useState({});
279
+ const [applying, setApplying] = useState(false);
280
+ const [applyResult, setApplyResult] = useState(null);
281
+ // Active thread id — set when a query response carries it, or when the
282
+ // sidecar is opened with initialThread (reopen flow). Sent on apply so
283
+ // the same governed row records both the proposal turn and its outcome.
284
+ const [threadId, setThreadId] = useState(null);
285
+ // Full multi-turn message history for the active thread. Drives the
286
+ // conversation UI and locks the pill row once the user has sent at
287
+ // least one message in this thread.
288
+ const [messages, setMessages] = useState([]);
289
+ // Active intent for this thread (locked once the first user message
290
+ // has been sent). Pills disappear after lock.
291
+ const [activeIntent, setActiveIntent] = useState(initialIntent || "create_object");
292
+ // "More" dropdown open state for the pill row.
293
+ const [moreOpen, setMoreOpen] = useState(false);
294
+ const moreMenuRef = useRef(null);
295
+
296
+ // Setup tab state
297
+ const [connectionStatus, setConnectionStatus] = useState(null);
298
+ const [pingLoading, setPingLoading] = useState(false);
299
+ // Editable draft for local-model / endpoint / adapter mode. Seeded from
300
+ // the live sandbox-environment row whenever the sidecar opens; writes
301
+ // back via PATCH /api/workspace { dataModel } and re-pings on save.
302
+ const [modelDraft, setModelDraft] = useState("");
303
+ const [endpointDraft, setEndpointDraft] = useState("");
304
+ const [adapterDraft, setAdapterDraft] = useState("ollama");
305
+ const [savingSetup, setSavingSetup] = useState(false);
306
+ const [setupSaveError, setSetupSaveError] = useState("");
307
+ const [setupSaveOk, setSetupSaveOk] = useState(false);
308
+ const [copiedCommand, setCopiedCommand] = useState(false);
309
+
310
+ // Drag state
311
+ const [panelWidth, setPanelWidth] = useState(persistedWidth);
312
+ const dragRef = useRef({ dragging: false, startX: 0, startWidth: 0 });
313
+ const sidecarRef = useRef(null);
314
+ const promptRef = useRef(null);
315
+
316
+ // Auto-anchor the conversation to the latest turn whenever a new
317
+ // message arrives or the assistant starts streaming. ChatGPT pattern —
318
+ // no scroll-to-bottom affordance, browser scroll behaviour is enough.
319
+ const conversationRef = useRef(null);
320
+ useEffect(() => {
321
+ const el = conversationRef.current;
322
+ if (!el) return;
323
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
324
+ }, [messages.length, streaming]);
325
+
326
+ useEffect(() => {
327
+ if (initialIntent) { setIntent(initialIntent); setActiveIntent(initialIntent); }
328
+ }, [initialIntent]);
329
+
330
+ // Seed the prompt textarea when the helper opens with a starter prompt
331
+ // (empty state CTA, palette intents). Only re-seeds when opening.
332
+ useEffect(() => {
333
+ if (open && initialPrompt) setPrompt(initialPrompt);
334
+ }, [open, initialPrompt]);
335
+
336
+ // Rehydrate the sidecar from a thread row when the user clicks Reopen
337
+ // inside the Helper Threads Data Model object. The whole prior turn —
338
+ // intent, prompt, summary, proposals, warnings, receipts, and the
339
+ // multi-turn message history — is restored so the user re-enters the
340
+ // conversation exactly where they left it.
341
+ useEffect(() => {
342
+ if (!open || !initialThread) return;
343
+ if (initialThread.intent) {
344
+ setIntent(initialThread.intent);
345
+ setActiveIntent(initialThread.intent);
346
+ }
347
+ if (typeof initialThread.prompt === "string") setPrompt("");
348
+ if (initialThread.id) setThreadId(initialThread.id);
349
+ if (Array.isArray(initialThread.messages)) {
350
+ setMessages(initialThread.messages);
351
+ } else {
352
+ setMessages([]);
353
+ }
354
+ if (initialThread.result && typeof initialThread.result === "object") {
355
+ setResult(initialThread.result);
356
+ const init = {};
357
+ (initialThread.result.proposals || []).forEach((_, i) => { init[i] = false; });
358
+ setAccepted(init);
359
+ setStreamBuffer(initialThread.result.summary || "");
360
+ } else {
361
+ setResult(null);
362
+ setAccepted({});
363
+ setStreamBuffer("");
364
+ }
365
+ setQueryError("");
366
+ // Rehydrate the last apply outcome from the governed thread row so
367
+ // ToolCallCard rows render after page refresh / Reopen, not just
368
+ // inside the live session. The apply route persists lastApplied[]
369
+ // with full payload, confidence, rationale per receipt.
370
+ const lastApplied = Array.isArray(initialThread.lastApplied) ? initialThread.lastApplied : [];
371
+ const lastSkipped = Array.isArray(initialThread.lastSkipped) ? initialThread.lastSkipped : [];
372
+ if (lastApplied.length > 0 || lastSkipped.length > 0) {
373
+ setApplyResult({
374
+ ok: true,
375
+ rehydrated: true,
376
+ applied: lastApplied.map((r) => ({ proposal: { ...r }, ...r })),
377
+ skipped: lastSkipped.map((s) => ({ proposal: { type: s.type, affectedField: s.affectedField, payload: s.payload || null }, reason: s.reason })),
378
+ });
379
+ } else {
380
+ setApplyResult(null);
381
+ }
382
+ }, [open, initialThread]);
383
+
384
+ // Move focus to the prompt textarea when the sidecar opens so the keyboard
385
+ // flow lands somewhere useful. Run after paint so the input is mounted.
386
+ useEffect(() => {
387
+ if (!open) return;
388
+ const t = setTimeout(() => {
389
+ try { promptRef.current?.focus(); } catch {}
390
+ }, 30);
391
+ return () => clearTimeout(t);
392
+ }, [open]);
393
+
394
+ useEffect(() => {
395
+ if (!open) {
396
+ setResult(null);
397
+ setQueryError("");
398
+ setStreamBuffer("");
399
+ setStreaming(false);
400
+ setAccepted({});
401
+ setApplyResult(null);
402
+ setActiveTab("assistant");
403
+ setConnectionStatus(null);
404
+ setThreadId(null);
405
+ setMessages([]);
406
+ setMoreOpen(false);
407
+ }
408
+ }, [open]);
409
+
410
+ // Close the "More" pill dropdown on outside click.
411
+ useEffect(() => {
412
+ if (!moreOpen) return undefined;
413
+ function onPointerDown(e) {
414
+ if (!moreMenuRef.current) return;
415
+ if (!moreMenuRef.current.contains(e.target)) setMoreOpen(false);
416
+ }
417
+ document.addEventListener("pointerdown", onPointerDown);
418
+ return () => document.removeEventListener("pointerdown", onPointerDown);
419
+ }, [moreOpen]);
420
+
421
+ // Escape key
422
+ useEffect(() => {
423
+ if (!open) return undefined;
424
+ const handler = (e) => { if (e.key === "Escape") onClose(); };
425
+ window.addEventListener("keydown", handler);
426
+ return () => window.removeEventListener("keydown", handler);
427
+ }, [open, onClose]);
428
+
429
+ // Cmd+Enter at the window level fires apply when there is a result with
430
+ // accepted proposals. The textarea handler stops propagation when the
431
+ // user is still composing a prompt, so submit and apply never collide.
432
+ const acceptedHasAny = Object.values(accepted).some(Boolean);
433
+ useEffect(() => {
434
+ if (!open) return undefined;
435
+ const handler = (e) => {
436
+ if (!((e.metaKey || e.ctrlKey) && e.key === "Enter")) return;
437
+ if (applying || !result || !acceptedHasAny) return;
438
+ e.preventDefault();
439
+ handleApply();
440
+ };
441
+ window.addEventListener("keydown", handler);
442
+ return () => window.removeEventListener("keydown", handler);
443
+ }, [open, applying, result, acceptedHasAny]);
444
+
445
+ // Drag handlers
446
+ const handleDragStart = useCallback((e) => {
447
+ e.preventDefault();
448
+ dragRef.current = { dragging: true, startX: e.clientX, startWidth: panelWidth };
449
+ document.body.style.userSelect = "none";
450
+ document.body.style.cursor = "ew-resize";
451
+ let currentWidth = panelWidth;
452
+ const onMove = (me) => {
453
+ if (!dragRef.current.dragging) return;
454
+ const dx = dragRef.current.startX - me.clientX;
455
+ const next = Math.min(
456
+ Math.max(dragRef.current.startWidth + dx, MIN_WIDTH),
457
+ window.innerWidth * MAX_WIDTH_VW
458
+ );
459
+ currentWidth = next;
460
+ setPanelWidth(next);
461
+ };
462
+ const onUp = () => {
463
+ dragRef.current.dragging = false;
464
+ persistedWidth = currentWidth;
465
+ document.body.style.userSelect = "";
466
+ document.body.style.cursor = "";
467
+ window.removeEventListener("mousemove", onMove);
468
+ window.removeEventListener("mouseup", onUp);
469
+ };
470
+ window.addEventListener("mousemove", onMove);
471
+ window.addEventListener("mouseup", onUp);
472
+ }, [panelWidth]);
473
+
474
+ // Proposal keyboard: Tab focuses next, Space toggles accept
475
+ const handleProposalKeyDown = useCallback((e, idx) => {
476
+ if (e.key === " ") {
477
+ e.preventDefault();
478
+ setAccepted((prev) => ({ ...prev, [idx]: !prev[idx] }));
479
+ }
480
+ }, []);
481
+
482
+ async function runQuery() {
483
+ if (!prompt.trim() || streaming) return;
484
+ setResult(null);
485
+ setQueryError("");
486
+ setStreamBuffer("");
487
+ setAccepted({});
488
+ setApplyResult(null);
489
+ setStreaming(true);
490
+ // Optimistically append the user's turn to the local message list so
491
+ // the conversation renders immediately while we wait for the assistant.
492
+ const userTurn = { role: "user", content: prompt.trim(), ts: new Date().toISOString() };
493
+ setMessages((prev) => [...prev, userTurn]);
494
+ const submittedPrompt = prompt.trim();
495
+ setPrompt("");
496
+
497
+ try {
498
+ const res = await fetch("/api/workspace/helper/query", {
499
+ method: "POST",
500
+ headers: { "content-type": "application/json" },
501
+ body: JSON.stringify({ intent: activeIntent, userPrompt: submittedPrompt, threadId: threadId || undefined }),
502
+ });
503
+
504
+ // Try streaming first
505
+ if (res.body) {
506
+ const reader = res.body.getReader();
507
+ const decoder = new TextDecoder();
508
+ let accumulated = "";
509
+
510
+ while (true) {
511
+ const { done, value } = await reader.read();
512
+ if (done) break;
513
+ accumulated += decoder.decode(value, { stream: true });
514
+ setStreamBuffer(accumulated);
515
+ }
516
+
517
+ let parsed;
518
+ try { parsed = JSON.parse(accumulated); } catch { parsed = null; }
519
+
520
+ if (parsed && typeof parsed === "object") {
521
+ if (!parsed.ok) {
522
+ setQueryError(parsed.error || "The helper could not complete this request.");
523
+ } else {
524
+ setResult(parsed);
525
+ if (parsed.threadId) setThreadId(parsed.threadId);
526
+ if (parsed.intent && parsed.intent !== activeIntent) setActiveIntent(parsed.intent);
527
+ if (Array.isArray(parsed.messages)) setMessages(parsed.messages);
528
+ const init = {};
529
+ (parsed.proposals || []).forEach((_, i) => { init[i] = true; });
530
+ setAccepted(init);
531
+ }
532
+ } else {
533
+ setQueryError("The helper response could not be read. Try again or open the Setup tab.");
534
+ }
535
+ } else {
536
+ // Non-streaming fallback
537
+ const data = await res.json();
538
+ if (!data.ok) {
539
+ setQueryError(data.error || "The helper could not complete this request.");
540
+ } else {
541
+ setResult(data);
542
+ if (data.threadId) setThreadId(data.threadId);
543
+ if (data.intent && data.intent !== activeIntent) setActiveIntent(data.intent);
544
+ if (Array.isArray(data.messages)) setMessages(data.messages);
545
+ const init = {};
546
+ (data.proposals || []).forEach((_, i) => { init[i] = true; });
547
+ setAccepted(init);
548
+ setStreamBuffer(data.summary || "");
549
+ }
550
+ }
551
+ } catch (err) {
552
+ setQueryError(humanizeError(err?.message));
553
+ } finally {
554
+ setStreaming(false);
555
+ }
556
+ }
557
+
558
+ async function handleApply() {
559
+ if (!result || applying) return;
560
+ const proposals = (result.proposals || []).filter((_, i) => accepted[i]);
561
+ if (!proposals.length) return;
562
+ setApplying(true);
563
+ setApplyResult(null);
564
+ try {
565
+ const res = await fetch("/api/workspace/helper/apply", {
566
+ method: "POST",
567
+ headers: { "content-type": "application/json" },
568
+ body: JSON.stringify({ proposals, reviewedBy: "user", threadId: threadId || undefined }),
569
+ });
570
+ const data = await res.json();
571
+ setApplyResult(data);
572
+ if (Array.isArray(data.messages)) setMessages(data.messages);
573
+ if (data.workspaceConfig && onApplied) onApplied(data.workspaceConfig);
574
+ } catch (err) {
575
+ setApplyResult({ ok: false, error: humanizeError(err?.message), applied: [], skipped: [] });
576
+ } finally {
577
+ setApplying(false);
578
+ }
579
+ }
580
+
581
+ async function pingConnection() {
582
+ const row = resolveSandboxEnvRow(workspaceConfig);
583
+ const endpoint = row?.localEndpoint || "";
584
+ if (!endpoint) { setConnectionStatus("unconfigured"); return; }
585
+ setPingLoading(true);
586
+ try {
587
+ // Probe a list-models style URL. Treat any 2xx/4xx response as
588
+ // "reachable" — the server is responding even if the path is unknown.
589
+ const candidates = candidatePingUrls(endpoint);
590
+ let reachable = false;
591
+ for (const url of candidates) {
592
+ try {
593
+ const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(3500) });
594
+ if (res.status > 0 && res.status < 600) { reachable = true; break; }
595
+ } catch { /* try next candidate */ }
596
+ }
597
+ setConnectionStatus(reachable ? "connected" : "unreachable");
598
+ } catch {
599
+ setConnectionStatus("unreachable");
600
+ } finally {
601
+ setPingLoading(false);
602
+ }
603
+ }
604
+
605
+ useEffect(() => {
606
+ if (open && activeTab === "setup") {
607
+ setConnectionStatus(null);
608
+ pingConnection();
609
+ }
610
+ // eslint-disable-next-line react-hooks/exhaustive-deps
611
+ }, [open, activeTab]);
612
+
613
+ const sandboxRow = resolveSandboxEnvRow(workspaceConfig);
614
+ const liveModel = sandboxRow?.localModel || "";
615
+ const liveEndpoint = sandboxRow?.localEndpoint || "";
616
+ const liveAdapter = sandboxRow?.intelligenceAdapterMode || "ollama";
617
+ const deploymentMode = liveAdapter === "custom-openai-compatible" || liveAdapter === "vllm" ? "hosted" : "local";
618
+ const isUnconfigured = !liveEndpoint;
619
+ const setupIsDirty =
620
+ modelDraft.trim() !== liveModel ||
621
+ endpointDraft.trim() !== liveEndpoint ||
622
+ adapterDraft !== liveAdapter;
623
+ const setupStatusState = pingLoading
624
+ ? "checking"
625
+ : connectionStatus === "connected"
626
+ ? "connected"
627
+ : isUnconfigured
628
+ ? "unconfigured"
629
+ : "unreachable";
630
+ const setupStatusLabel = {
631
+ checking: "Checking connection…",
632
+ connected: "Connected to your local model",
633
+ unconfigured: "No local model configured yet",
634
+ unreachable: "Could not reach your local model",
635
+ }[setupStatusState];
636
+ const setupStatusMeta = {
637
+ checking: "",
638
+ connected: liveModel && liveEndpoint ? `${liveModel} · ${liveEndpoint}` : "",
639
+ unconfigured: "Configure your local model below to start using the helper.",
640
+ unreachable: "Start your local Ollama / LM Studio server, or verify the endpoint URL.",
641
+ }[setupStatusState];
642
+
643
+ // Seed setup-tab drafts whenever the sidecar opens or the underlying
644
+ // sandbox row changes. Drafts mirror the live row on open and diverge
645
+ // only after the user edits a field.
646
+ useEffect(() => {
647
+ if (!open) return;
648
+ setModelDraft(liveModel);
649
+ setEndpointDraft(liveEndpoint);
650
+ setAdapterDraft(liveAdapter || "ollama");
651
+ setSetupSaveError("");
652
+ setSetupSaveOk(false);
653
+ }, [open, liveModel, liveEndpoint, liveAdapter]);
654
+
655
+ async function saveSetup() {
656
+ if (savingSetup) return;
657
+ setSavingSetup(true);
658
+ setSetupSaveError("");
659
+ setSetupSaveOk(false);
660
+ try {
661
+ const dm = workspaceConfig?.dataModel || {};
662
+ const objects = Array.isArray(dm.objects) ? dm.objects.slice() : [];
663
+ const sbIdx = objects.findIndex(
664
+ (o) => o?.objectType === "sandbox-environment" && Array.isArray(o?.rows) && o.rows.length > 0,
665
+ );
666
+ let nextObjects;
667
+ if (sbIdx >= 0) {
668
+ const obj = objects[sbIdx];
669
+ const rows = obj.rows.slice();
670
+ rows[0] = {
671
+ ...rows[0],
672
+ localModel: modelDraft.trim(),
673
+ localEndpoint: endpointDraft.trim(),
674
+ intelligenceAdapterMode: adapterDraft,
675
+ };
676
+ objects[sbIdx] = { ...obj, rows };
677
+ nextObjects = objects;
678
+ } else {
679
+ // First-time setup: seed a minimal sandbox-environment object so the
680
+ // helper has a row to read at request time.
681
+ objects.push({
682
+ id: "workspace-helper-sandbox",
683
+ label: "Workspace Helper Sandbox",
684
+ source: "Workspace Helper Sandbox",
685
+ objectType: "sandbox-environment",
686
+ icon: "Terminal",
687
+ columns: [
688
+ "Name", "lifecycleStatus", "runLocality", "runtime",
689
+ "intelligenceType", "localModel", "localEndpoint", "intelligenceAdapterMode",
690
+ ],
691
+ rows: [{
692
+ Name: "workspace-helper",
693
+ lifecycleStatus: "live",
694
+ runLocality: "local",
695
+ runtime: "node",
696
+ intelligenceType: "local-intelligence",
697
+ localModel: modelDraft.trim(),
698
+ localEndpoint: endpointDraft.trim(),
699
+ intelligenceAdapterMode: adapterDraft,
700
+ }],
701
+ binding: { mode: "manual", source: "Workspace Helper Sandbox" },
702
+ });
703
+ nextObjects = objects;
704
+ }
705
+ const res = await fetch("/api/workspace", {
706
+ method: "PATCH",
707
+ headers: { "content-type": "application/json" },
708
+ body: JSON.stringify({ dataModel: { ...dm, objects: nextObjects } }),
709
+ });
710
+ const body = await res.json().catch(() => ({}));
711
+ if (!res.ok) {
712
+ setSetupSaveError(body?.error || "Could not save. Try again or check the workspace persistence mode.");
713
+ return;
714
+ }
715
+ if (body?.workspaceConfig && onApplied) {
716
+ onApplied(body.workspaceConfig);
717
+ }
718
+ setSetupSaveOk(true);
719
+ setConnectionStatus(null);
720
+ await pingConnection();
721
+ } catch (err) {
722
+ setSetupSaveError(err?.message || "Save failed");
723
+ } finally {
724
+ setSavingSetup(false);
725
+ }
726
+ }
727
+
728
+ function copyCommand() {
729
+ try {
730
+ navigator.clipboard.writeText("growthub workspace setup --open");
731
+ setCopiedCommand(true);
732
+ window.setTimeout(() => setCopiedCommand(false), 1200);
733
+ } catch {
734
+ // Clipboard API unavailable — surface no feedback; users can copy by hand.
735
+ }
736
+ }
737
+
738
+ const acceptedCount = Object.values(accepted).filter(Boolean).length;
739
+ const skippedCount = applyResult?.skipped?.length || 0;
740
+ const hasProposals = result && (result.proposals || []).length > 0;
741
+
742
+ // Thread is "active" the moment the user has sent at least one message,
743
+ // OR we have rehydrated a prior thread row. Pills only show on the
744
+ // initial empty state of a brand-new thread.
745
+ const threadActive = messages.some((m) => m?.role === "user");
746
+ const intentHint = HELPER_INTENT_HINTS[activeIntent] || "";
747
+
748
+ const onPickIntent = (next) => {
749
+ setIntent(next);
750
+ setActiveIntent(next);
751
+ setMoreOpen(false);
752
+ try { promptRef.current?.focus(); } catch {}
753
+ };
754
+
755
+ if (!open) return null;
756
+
757
+ return (
758
+ <>
759
+ <div className="dm-sidecar-backdrop" onClick={onClose} aria-hidden="true" />
760
+ <aside
761
+ ref={sidecarRef}
762
+ className="dm-helper-sidecar"
763
+ data-helper-sidecar=""
764
+ role="dialog"
765
+ aria-label="Workspace helper"
766
+ aria-modal="true"
767
+ style={{ width: panelWidth }}
768
+ >
769
+ {/* Drag handle */}
770
+ <div
771
+ className="dm-sidecar-drag-handle"
772
+ data-drag-handle=""
773
+ onMouseDown={handleDragStart}
774
+ title="Drag to resize"
775
+ aria-hidden="true"
776
+ />
777
+
778
+ {/* Header — title left; gear toggles Assistant ↔ Setup, then close. */}
779
+ <div className="dm-sidecar-header">
780
+ <div className="dm-sidecar-header-left">
781
+ <span className="dm-sidecar-title" data-helper-title="">
782
+ {threadActive
783
+ ? deriveThreadDisplayTitle(initialThread, "Workspace Helper")
784
+ : "Workspace Helper"}
785
+ </span>
786
+ </div>
787
+ <div className="dm-sidecar-header-right">
788
+ <button
789
+ type="button"
790
+ className="dm-sidecar-icon-btn"
791
+ onClick={() => setActiveTab((current) => (current === "setup" ? "assistant" : "setup"))}
792
+ aria-label={activeTab === "setup" ? "Back" : "Setup"}
793
+ title={activeTab === "setup" ? "Back" : "Setup"}
794
+ data-tab={activeTab === "setup" ? "assistant" : "setup"}
795
+ >
796
+ {activeTab === "setup" ? <ArrowLeft size={14} /> : <Settings size={14} />}
797
+ </button>
798
+ <button
799
+ type="button"
800
+ className="dm-sidecar-icon-btn"
801
+ onClick={onClose}
802
+ aria-label="Close workspace helper"
803
+ title="Close"
804
+ >
805
+ <X size={14} />
806
+ </button>
807
+ </div>
808
+ </div>
809
+
810
+ {/* Assistant tab — composer-at-bottom layout (Twenty Ask AI parity):
811
+ conversation/result area on top (flex:1), bottom-anchored composer
812
+ holds chip stack (empty state) → mode row (active thread) →
813
+ textarea with attach + mode + send-arrow action row. */}
814
+ {activeTab === "assistant" && (
815
+ <div className="dm-sidecar-body dm-helper-body">
816
+ <div className="dm-helper-conversation" ref={conversationRef}>
817
+ {/* Conversation — ChatGPT-grade multi-turn. User bubble
818
+ right (grey, fits-content), assistant turn left (no
819
+ chip, full-width markdown via react-markdown + GFM).
820
+ Subtle dividers between turns. */}
821
+ {threadActive && messages.length > 0 && (
822
+ <div className="dm-helper-messages" data-helper-messages="">
823
+ {messages.map((m, i) => {
824
+ if (m.role !== "user" && m.role !== "assistant" && m.role !== "system") return null;
825
+ const userText = m.role === "user" ? (m.content || "") : "";
826
+ const assistantMarkdown = m.role === "assistant"
827
+ ? (m.summary || extractAssistantSummary(m.content) || "")
828
+ : "";
829
+ // System turns are governed apply-receipts emitted by
830
+ // /api/workspace/helper/apply. We render them as a
831
+ // ToolCallCard sitting BELOW the preceding assistant
832
+ // reply (OpenAI tool-call grammar) — the chevron
833
+ // accordion exposes the receipt's full payload so the
834
+ // user can audit what was actually applied. The
835
+ // matching proposal payload comes from the row's
836
+ // `lastApplied[]` we rehydrated into applyResult.
837
+ if (m.role === "system") {
838
+ const receipt = resolveSystemReceipt(m, applyResult);
839
+ return (
840
+ <div
841
+ key={i}
842
+ className="dm-helper-turn role-system"
843
+ data-helper-message="system"
844
+ >
845
+ <ToolCallCard
846
+ proposal={receipt}
847
+ content={m.content || ""}
848
+ onOpenArtifact={(p) => {
849
+ if (typeof onOpenArtifact === "function") {
850
+ onOpenArtifact(resolveArtifactTarget(p), p);
851
+ }
852
+ }}
853
+ />
854
+ </div>
855
+ );
856
+ }
857
+ return (
858
+ <div
859
+ key={i}
860
+ className={`dm-helper-turn role-${m.role}`}
861
+ data-helper-message={m.role}
862
+ >
863
+ {m.role === "user" && (
864
+ <div className="dm-helper-bubble dm-helper-bubble-user">{userText}</div>
865
+ )}
866
+ {m.role === "assistant" && (
867
+ <div className="dm-helper-bubble dm-helper-bubble-assistant">
868
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
869
+ {assistantMarkdown || "_(no response)_"}
870
+ </ReactMarkdown>
871
+ </div>
872
+ )}
873
+ </div>
874
+ );
875
+ })}
876
+ {streaming && (
877
+ <div className="dm-helper-turn role-assistant" data-helper-message="assistant-pending">
878
+ <div className="dm-helper-bubble dm-helper-bubble-assistant dm-helper-bubble-pending">
879
+ <span className="dm-helper-typing" aria-label="Assistant is thinking">
880
+ <span className="dm-helper-typing-dot" />
881
+ <span className="dm-helper-typing-dot" />
882
+ <span className="dm-helper-typing-dot" />
883
+ </span>
884
+ </div>
885
+ </div>
886
+ )}
887
+ </div>
888
+ )}
889
+
890
+ {queryError && (
891
+ <div className="dm-helper-error" role="alert">
892
+ <AlertCircle size={13} />
893
+ <span>{queryError}</span>
894
+ </div>
895
+ )}
896
+
897
+ {/* Streaming surface — only used before the thread activates
898
+ (first turn). After activation the conversation list owns
899
+ the live-thinking affordance via its pending bubble. */}
900
+ {!threadActive && (streaming || streamBuffer) && !result && (
901
+ <div className="dm-helper-stream" data-helper-stream="">
902
+ <span>{streamBuffer}</span>
903
+ {streaming && <span className="dm-stream-cursor" aria-hidden="true">|</span>}
904
+ </div>
905
+ )}
906
+
907
+ {/* Proposals */}
908
+ {result && (
909
+ <div className="dm-helper-result">
910
+ <div className="dm-helper-summary">
911
+ <span>{result.summary}</span>
912
+ </div>
913
+
914
+ {(result.warnings || []).length > 0 && (
915
+ <div className="dm-helper-warnings">
916
+ {result.warnings.map((w, i) => (
917
+ <div key={i} className="dm-helper-warning">
918
+ <AlertCircle size={12} />
919
+ <span>{w}</span>
920
+ </div>
921
+ ))}
922
+ </div>
923
+ )}
924
+
925
+ {hasProposals && (
926
+ <>
927
+ <div className="dm-helper-proposals-header">
928
+ <span className="dm-field-label">Proposals · review before applying</span>
929
+ <span className="dm-field-hint">{acceptedCount} of {result.proposals.length} selected</span>
930
+ </div>
931
+ <div
932
+ className="dm-helper-proposals"
933
+ role="group"
934
+ aria-label="Proposals"
935
+ >
936
+ {result.proposals.map((proposal, i) => {
937
+ const summary = summarizePayload(proposal);
938
+ const conf = typeof proposal.confidence === "number" ? Math.round(proposal.confidence * 100) : null;
939
+ return (
940
+ <label
941
+ key={i}
942
+ className={`dm-helper-proposal${accepted[i] ? " accepted" : ""}`}
943
+ data-proposal-item=""
944
+ tabIndex={0}
945
+ onKeyDown={(e) => handleProposalKeyDown(e, i)}
946
+ >
947
+ <input
948
+ type="checkbox"
949
+ checked={!!accepted[i]}
950
+ onChange={(e) =>
951
+ setAccepted((prev) => ({ ...prev, [i]: e.target.checked }))
952
+ }
953
+ disabled={applying}
954
+ data-proposal-accept=""
955
+ tabIndex={-1}
956
+ />
957
+ <div className="dm-helper-proposal-body">
958
+ <div className="dm-helper-proposal-row">
959
+ <span className="dm-helper-proposal-type">{proposal.type}</span>
960
+ <span className="dm-helper-proposal-field">→ {proposal.affectedField}</span>
961
+ {conf !== null && (
962
+ <span className="dm-helper-proposal-confidence" data-proposal-confidence={conf}>
963
+ {conf}%
964
+ </span>
965
+ )}
966
+ </div>
967
+ {summary && (
968
+ <p className="dm-helper-proposal-payload" data-proposal-payload="">{summary}</p>
969
+ )}
970
+ <p className="dm-helper-proposal-rationale">{proposal.rationale}</p>
971
+ </div>
972
+ </label>
973
+ );
974
+ })}
975
+ </div>
976
+
977
+ {!applyResult && (
978
+ <button
979
+ type="button"
980
+ className="dm-btn-primary"
981
+ style={{ width: "100%", marginTop: 8 }}
982
+ onClick={handleApply}
983
+ disabled={applying || acceptedCount === 0}
984
+ data-helper-apply=""
985
+ >
986
+ {applying
987
+ ? "Applying…"
988
+ : `Apply ${acceptedCount} proposal${acceptedCount === 1 ? "" : "s"}`}
989
+ </button>
990
+ )}
991
+ </>
992
+ )}
993
+
994
+ {result && !hasProposals && !queryError && (
995
+ <div className="dm-helper-empty-hint">
996
+ <p>The helper did not produce any proposals for this request. Try rewording or pick a different intent.</p>
997
+ </div>
998
+ )}
999
+
1000
+ {applyResult && (
1001
+ <div
1002
+ className={`dm-helper-apply-result${applyResult.ok ? "" : " is-error"}`}
1003
+ data-helper-apply-result=""
1004
+ role="status"
1005
+ >
1006
+ {applyResult.ok ? (
1007
+ <>
1008
+ <CheckSquare size={14} />
1009
+ <span>
1010
+ {applyResult.applied?.length || 0} applied
1011
+ {skippedCount > 0 && (
1012
+ <span data-skipped-count="">
1013
+ , {skippedCount} skipped
1014
+ </span>
1015
+ )}
1016
+ </span>
1017
+ </>
1018
+ ) : (
1019
+ <>
1020
+ <AlertCircle size={14} />
1021
+ <span>{applyResult.error || "Apply failed"}</span>
1022
+ {skippedCount > 0 && (
1023
+ <span data-skipped-count="">
1024
+ {skippedCount} skipped
1025
+ </span>
1026
+ )}
1027
+ </>
1028
+ )}
1029
+ </div>
1030
+ )}
1031
+
1032
+ {/* No separate tool-call stack — each apply receipt is a
1033
+ `system` message in the conversation above and renders
1034
+ as a ToolCallCard inline (OpenAI tool-call grammar). */}
1035
+
1036
+ {applyResult?.skipped?.length > 0 && (
1037
+ <div className="dm-helper-skipped" data-helper-skipped="">
1038
+ <span className="dm-field-label">Skipped</span>
1039
+ {applyResult.skipped.map((s, i) => (
1040
+ <div key={i} className="dm-helper-skipped-row">
1041
+ <span className="dm-helper-proposal-type">{s.proposal?.type || "unknown"}</span>
1042
+ <span className="dm-helper-skipped-reason">{s.reason || "no reason"}</span>
1043
+ </div>
1044
+ ))}
1045
+ </div>
1046
+ )}
1047
+
1048
+ {result.receipts && (
1049
+ <p className="dm-field-hint" style={{ marginTop: 8 }} data-helper-receipt="">
1050
+ Run: {result.receipts.model} · confidence{" "}
1051
+ {typeof result.receipts.confidence === "number"
1052
+ ? `${Math.round(result.receipts.confidence * 100)}%`
1053
+ : "n/a"}{" "}
1054
+ · {result.receipts.latencyMs}ms
1055
+ </p>
1056
+ )}
1057
+ </div>
1058
+ )}
1059
+ </div>
1060
+
1061
+ {/* Composer — pinned at the bottom of the sidecar body. Empty
1062
+ state surfaces a chip stack of intents (Twenty Ask AI
1063
+ grammar); active thread shows the locked mode + a textarea
1064
+ with attach (left) + mode + send (right). */}
1065
+ <div className="dm-helper-composer" data-helper-composer="">
1066
+ {!threadActive ? (
1067
+ <>
1068
+ <p className="dm-helper-composer-prompt">What can I help you with?</p>
1069
+ <div className="dm-helper-chip-stack" role="group" aria-label="Pick an intent">
1070
+ {PRIMARY_INTENT_VALUES.map((value) => {
1071
+ const Icon = INTENT_ICON[value] || Plus;
1072
+ const isActive = activeIntent === value;
1073
+ return (
1074
+ <button
1075
+ key={value}
1076
+ type="button"
1077
+ className={`dm-helper-chip${isActive ? " active" : ""}`}
1078
+ data-helper-pill={value}
1079
+ aria-pressed={isActive}
1080
+ disabled={streaming}
1081
+ onClick={() => onPickIntent(value)}
1082
+ >
1083
+ <Icon size={15} aria-hidden="true" />
1084
+ <span>{intentLabel(value)}</span>
1085
+ </button>
1086
+ );
1087
+ })}
1088
+ <span className="dm-helper-chip-more-wrap" ref={moreMenuRef}>
1089
+ <button
1090
+ type="button"
1091
+ className={`dm-helper-chip dm-helper-chip-more${MORE_INTENT_VALUES.includes(activeIntent) ? " active" : ""}`}
1092
+ data-helper-pill="more"
1093
+ aria-haspopup="listbox"
1094
+ aria-expanded={moreOpen}
1095
+ disabled={streaming}
1096
+ onClick={() => setMoreOpen((v) => !v)}
1097
+ >
1098
+ <span>{MORE_INTENT_VALUES.includes(activeIntent) ? intentLabel(activeIntent) : "More"}</span>
1099
+ <ChevronDown size={12} aria-hidden="true" />
1100
+ </button>
1101
+ {moreOpen && (
1102
+ <div className="dm-helper-pill-menu" role="listbox" data-helper-pill-menu="">
1103
+ {MORE_INTENT_VALUES.map((value) => {
1104
+ const Icon = INTENT_ICON[value] || Plus;
1105
+ return (
1106
+ <button
1107
+ key={value}
1108
+ type="button"
1109
+ className={`dm-helper-pill-menu-item${activeIntent === value ? " active" : ""}`}
1110
+ data-helper-pill={value}
1111
+ role="option"
1112
+ aria-selected={activeIntent === value}
1113
+ onClick={() => onPickIntent(value)}
1114
+ >
1115
+ <Icon size={13} aria-hidden="true" />
1116
+ {intentLabel(value)}
1117
+ </button>
1118
+ );
1119
+ })}
1120
+ </div>
1121
+ )}
1122
+ </span>
1123
+ </div>
1124
+ </>
1125
+ ) : null}
1126
+
1127
+ <div className="dm-helper-composer-input">
1128
+ <textarea
1129
+ id="helper-prompt"
1130
+ ref={promptRef}
1131
+ className="dm-helper-composer-textarea"
1132
+ rows={threadActive ? 2 : 3}
1133
+ placeholder={threadActive
1134
+ ? 'Continue the conversation…'
1135
+ : 'Ask, search or make anything…'}
1136
+ value={prompt}
1137
+ onChange={(e) => setPrompt(e.target.value)}
1138
+ disabled={streaming}
1139
+ data-helper-prompt=""
1140
+ aria-label="Helper prompt"
1141
+ onKeyDown={(e) => {
1142
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
1143
+ e.preventDefault();
1144
+ // Stop the window-level apply handler from firing on the
1145
+ // same keystroke while the user is still drafting.
1146
+ e.stopPropagation();
1147
+ runQuery();
1148
+ }
1149
+ }}
1150
+ />
1151
+ <div className="dm-helper-composer-actions">
1152
+ <button
1153
+ type="button"
1154
+ className="dm-helper-composer-attach"
1155
+ aria-label="Attach files (coming soon)"
1156
+ title="Attach files — coming soon"
1157
+ disabled
1158
+ >
1159
+ <Paperclip size={14} aria-hidden="true" />
1160
+ </button>
1161
+ <div className="dm-helper-composer-actions-right">
1162
+ <button
1163
+ type="button"
1164
+ className="dm-helper-composer-send"
1165
+ onClick={runQuery}
1166
+ disabled={streaming || !prompt.trim()}
1167
+ data-helper-submit=""
1168
+ aria-label={streaming ? "Sending" : "Send (⌘+Enter)"}
1169
+ title={streaming ? "Sending…" : `Send · ${intentLabel(activeIntent)} (⌘+Enter)`}
1170
+ >
1171
+ {streaming ? (
1172
+ <span className="dm-stream-cursor" aria-hidden="true">…</span>
1173
+ ) : (
1174
+ <ArrowUp size={14} aria-hidden="true" />
1175
+ )}
1176
+ </button>
1177
+ </div>
1178
+ </div>
1179
+ </div>
1180
+
1181
+ {!threadActive && intentHint && (
1182
+ <p className="dm-helper-composer-hint" data-helper-intent-hint="">
1183
+ {intentHint}
1184
+ </p>
1185
+ )}
1186
+ </div>
1187
+ </div>
1188
+ )}
1189
+
1190
+ {/* Setup tab — state-driven status hero + editable form + quick
1191
+ model swap + reconnect-on-save. Matches the Assistant tab's
1192
+ ChatGPT-clean rhythm; no bloat, no novelty. */}
1193
+ {activeTab === "setup" && (
1194
+ <div className="dm-sidecar-body dm-helper-setup-body">
1195
+ <p className="dm-helper-setup-intro">
1196
+ The helper sends your prompt to a local model. Credentials are never stored in the workspace.
1197
+ </p>
1198
+
1199
+ <div
1200
+ className={`dm-helper-setup-status state-${setupStatusState}`}
1201
+ data-connection-status=""
1202
+ data-connection-state={setupStatusState}
1203
+ >
1204
+ <div className="dm-helper-setup-status-row">
1205
+ <span className={`dm-connection-dot dm-connection-${
1206
+ setupStatusState === "connected"
1207
+ ? "ok"
1208
+ : setupStatusState === "checking"
1209
+ ? "checking"
1210
+ : "amber"
1211
+ }`} />
1212
+ <span className="dm-helper-setup-status-label">{setupStatusLabel}</span>
1213
+ <button
1214
+ type="button"
1215
+ className="dm-helper-setup-recheck"
1216
+ onClick={() => { setConnectionStatus(null); pingConnection(); }}
1217
+ disabled={pingLoading}
1218
+ aria-label="Re-check connection"
1219
+ >
1220
+ {pingLoading ? "Checking…" : "Re-check"}
1221
+ </button>
1222
+ </div>
1223
+ {setupStatusMeta && (
1224
+ <span className="dm-helper-setup-status-meta">{setupStatusMeta}</span>
1225
+ )}
1226
+ </div>
1227
+
1228
+ <div className="dm-helper-setup-section">
1229
+ <label className="dm-helper-setup-label" htmlFor="setup-model">Local Model</label>
1230
+ <input
1231
+ id="setup-model"
1232
+ type="text"
1233
+ className="dm-helper-setup-input"
1234
+ value={modelDraft}
1235
+ onChange={(e) => setModelDraft(e.target.value)}
1236
+ placeholder="e.g. gemma3:4b"
1237
+ autoComplete="off"
1238
+ spellCheck={false}
1239
+ data-local-model=""
1240
+ />
1241
+ <div className="dm-helper-setup-quick-row" role="group" aria-label="Quick model swap">
1242
+ <span className="dm-helper-setup-quick-label">Quick swap</span>
1243
+ {SETUP_QUICK_MODELS.map((m) => (
1244
+ <button
1245
+ key={m}
1246
+ type="button"
1247
+ className={`dm-helper-setup-quick-pill${modelDraft === m ? " active" : ""}`}
1248
+ onClick={() => setModelDraft(m)}
1249
+ >
1250
+ {m}
1251
+ </button>
1252
+ ))}
1253
+ </div>
1254
+ </div>
1255
+
1256
+ <div className="dm-helper-setup-section">
1257
+ <label className="dm-helper-setup-label" htmlFor="setup-endpoint">Inference Endpoint</label>
1258
+ <input
1259
+ id="setup-endpoint"
1260
+ type="text"
1261
+ className="dm-helper-setup-input"
1262
+ value={endpointDraft}
1263
+ onChange={(e) => setEndpointDraft(e.target.value)}
1264
+ placeholder="http://127.0.0.1:11434/v1"
1265
+ autoComplete="off"
1266
+ spellCheck={false}
1267
+ data-local-endpoint=""
1268
+ />
1269
+ </div>
1270
+
1271
+ <div className="dm-helper-setup-section">
1272
+ <label className="dm-helper-setup-label" htmlFor="setup-adapter">Adapter Mode</label>
1273
+ <select
1274
+ id="setup-adapter"
1275
+ className="dm-helper-setup-select"
1276
+ value={adapterDraft}
1277
+ onChange={(e) => setAdapterDraft(e.target.value)}
1278
+ data-adapter-mode={adapterDraft}
1279
+ >
1280
+ <option value="ollama">Ollama</option>
1281
+ <option value="lmstudio">LM Studio</option>
1282
+ <option value="vllm">vLLM</option>
1283
+ <option value="custom-openai-compatible">Custom OpenAI-compatible</option>
1284
+ </select>
1285
+ <span className="dm-helper-setup-helper-text" data-deployment-mode={deploymentMode}>
1286
+ Deployment: <strong>{deploymentMode}</strong>
1287
+ </span>
1288
+ </div>
1289
+
1290
+ <div className="dm-helper-setup-actions">
1291
+ <button
1292
+ type="button"
1293
+ className="dm-helper-setup-save"
1294
+ onClick={saveSetup}
1295
+ disabled={savingSetup || !setupIsDirty || (!modelDraft.trim() || !endpointDraft.trim())}
1296
+ data-setup-save=""
1297
+ >
1298
+ {savingSetup
1299
+ ? "Saving…"
1300
+ : isUnconfigured
1301
+ ? "Save & connect"
1302
+ : "Save changes"}
1303
+ </button>
1304
+ {setupSaveOk && !setupIsDirty && (
1305
+ <span className="dm-helper-setup-save-ok">Saved</span>
1306
+ )}
1307
+ {setupSaveError && (
1308
+ <span className="dm-helper-setup-save-error" role="alert">{setupSaveError}</span>
1309
+ )}
1310
+ </div>
1311
+
1312
+ <div className="dm-helper-setup-guide">
1313
+ <p className="dm-helper-setup-label">Need help getting set up?</p>
1314
+ <pre className="dm-helper-setup-command" data-setup-command="">growthub workspace setup --open</pre>
1315
+ <button
1316
+ type="button"
1317
+ className="dm-helper-setup-copy"
1318
+ onClick={copyCommand}
1319
+ aria-label="Copy setup command"
1320
+ >
1321
+ {copiedCommand ? "Copied" : "Copy command"}
1322
+ </button>
1323
+ </div>
1324
+ </div>
1325
+ )}
1326
+ </aside>
1327
+ </>
1328
+ );
1329
+ }
1330
+
1331
+ // Produce a short, non-leaky error label from a thrown message.
1332
+ function humanizeError(msg) {
1333
+ const text = String(msg || "").trim();
1334
+ if (!text) return "Request failed";
1335
+ if (text.length < 140) return text;
1336
+ return "Request failed. Try again or check the Setup tab.";
1337
+ }
1338
+
1339
+ // Assistant turns are stored as a JSON envelope string. Pull the human
1340
+ // `summary` line out for the conversation list. Falls back to the raw
1341
+ // content when the envelope is unparseable.
1342
+ function extractAssistantSummary(raw) {
1343
+ if (typeof raw !== "string") return "";
1344
+ const text = raw.trim();
1345
+ if (!text) return "";
1346
+ if (text.startsWith("{") || text.startsWith("[")) {
1347
+ try {
1348
+ const obj = JSON.parse(text);
1349
+ if (obj && typeof obj.summary === "string" && obj.summary.trim()) return obj.summary.trim();
1350
+ } catch {
1351
+ // fall through
1352
+ }
1353
+ }
1354
+ return text;
1355
+ }
1356
+
1357
+ // Build candidate URLs for probing a local model endpoint. We accept any
1358
+ // 2xx/4xx as "reachable" because the server is alive even when the probed
1359
+ // path is unknown (different vendors use different routes).
1360
+ function candidatePingUrls(endpoint) {
1361
+ const base = String(endpoint || "").trim().replace(/\/+$/, "");
1362
+ if (!base) return [];
1363
+ const isV1 = /\/v1$/.test(base);
1364
+ const root = isV1 ? base.replace(/\/v1$/, "") : base;
1365
+ const urls = new Set();
1366
+ urls.add(`${base}/models`); // OpenAI-compatible (Ollama /v1/models, LM Studio, vLLM)
1367
+ urls.add(`${root}/api/tags`); // Ollama native
1368
+ urls.add(`${root}/`); // generic root probe
1369
+ urls.add(`${base}/`);
1370
+ return Array.from(urls);
1371
+ }