@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +307 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +372 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/receipts/route.js +47 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +664 -82
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +1371 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1383 -24
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +7 -21
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/ownership-panel.jsx +222 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/page.jsx +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +2 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +116 -24
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +497 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +20 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +19 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +473 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +583 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +34 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +3 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/export-training-traces.mjs +144 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/grade-raw-pairs.mjs +279 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/harvest-cursor-traces.mjs +288 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/upload-graded-traces.mjs +128 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/alignment-loop.config.json +264 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/workers/custom-workspace-operator/CLAUDE.md +38 -0
- package/dist/index.js +1416 -2627
- 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
|
+
}
|