@growthub/cli 0.14.0 → 0.14.1

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 (23) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +99 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +1 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +70 -9
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +1 -1
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +61 -35
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +18 -4
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +264 -22
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +81 -10
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +70 -85
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SidecarExpandView.jsx +37 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SwarmRunCockpit.jsx +625 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +150 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +129 -3
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +48 -9
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +139 -4
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +4 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +246 -4
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +6 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +411 -1
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +23 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +8 -6
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +551 -0
  23. package/package.json +1 -1
@@ -47,6 +47,14 @@ import {
47
47
  isHelperConfigured,
48
48
  WorkspaceHelperSetupModal,
49
49
  } from "../../components/WorkspaceHelperSetupModal.jsx";
50
+ import { SwarmRunCockpit, SwarmAgentTranscript } from "./SwarmRunCockpit.jsx";
51
+ import { SidecarExpandView } from "./SidecarExpandView.jsx";
52
+ import { parseSlashInput } from "./helper-commands.js";
53
+ import {
54
+ deriveHelperWidgetCausationState,
55
+ summarizeSwarmRunProposal,
56
+ SWARM_WORKFLOWS_OBJECT_ID,
57
+ } from "@/lib/workspace-swarm-proposal";
50
58
 
51
59
  // Generic "Tool Call Output" title matches the reference grammar — the
52
60
  // user already sees the prompt + assistant response in the chat above,
@@ -191,8 +199,16 @@ function resolveSystemReceipt(message, applyResult) {
191
199
  // Resolve where the Open button should navigate based on the proposal
192
200
  // shape. Returns null when no navigation makes sense (e.g. explain.object).
193
201
  function resolveArtifactTarget(proposal) {
202
+ // Apply receipts in the swarm lane carry an explicit artifact target.
203
+ if (proposal?.artifact?.surface === "swarm-run") return proposal.artifact;
194
204
  const pl = proposal?.payload || {};
195
205
  switch (proposal?.type) {
206
+ case "swarm.run.propose":
207
+ case "swarm.workflow.save":
208
+ case "swarm.run.resume":
209
+ return pl.name
210
+ ? { surface: "swarm-run", objectId: SWARM_WORKFLOWS_OBJECT_ID, name: pl.name }
211
+ : null;
196
212
  case "dataModel.object.create":
197
213
  case "dataModel.object.update":
198
214
  return pl.label || pl.id ? { surface: "data-model", source: pl.label || pl.id } : null;
@@ -321,12 +337,16 @@ function summarizePayload(proposal) {
321
337
  return p.objectId ? `binding for: ${p.objectId}` : "";
322
338
  case "explain.object":
323
339
  return p.target ? `about: ${p.target}` : "informational";
340
+ case "swarm.run.propose":
341
+ case "swarm.workflow.save":
342
+ case "swarm.run.resume":
343
+ return summarizeSwarmRunProposal(proposal);
324
344
  default:
325
345
  return "";
326
346
  }
327
347
  }
328
348
 
329
- export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, initialPrompt, initialThread, onApplied, onOpenArtifact }) {
349
+ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, initialPrompt, initialThread, onApplied, onOpenArtifact, onOpenSwarmWorkflow }) {
330
350
  const [activeTab, setActiveTab] = useState("assistant");
331
351
  const [intent, setIntent] = useState(initialIntent || "create_object");
332
352
  const [prompt, setPrompt] = useState(initialPrompt || "");
@@ -352,6 +372,22 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
352
372
  const [moreOpen, setMoreOpen] = useState(false);
353
373
  const moreMenuRef = useRef(null);
354
374
 
375
+ // Sidecar internal view (SWARM_RUN_CONTRACT_V1) — the swarm cockpit lives
376
+ // INSIDE the helper sidecar, no new route. "chat" is the existing
377
+ // conversation surface; swarm views render the governed run cockpit;
378
+ // "tool-output" is the expanded transcript frame.
379
+ const [activeView, setActiveView] = useState("chat");
380
+ // Focused workflow row when opened from an apply receipt's Open button.
381
+ const [swarmFocus, setSwarmFocus] = useState(null);
382
+ // Expanded transcript agent (tool-output view) + full-width takeover flag.
383
+ const [expandedAgent, setExpandedAgent] = useState(null);
384
+ const [expandActive, setExpandActive] = useState(false);
385
+ const priorViewRef = useRef("swarm-list");
386
+ // Slash menu — active index; "dismissed" suppresses the menu until the
387
+ // prompt changes again (Esc behavior).
388
+ const [slashIndex, setSlashIndex] = useState(0);
389
+ const [slashDismissed, setSlashDismissed] = useState(false);
390
+
355
391
  // Setup tab state
356
392
  const [connectionStatus, setConnectionStatus] = useState(null);
357
393
  const [pingLoading, setPingLoading] = useState(false);
@@ -464,6 +500,12 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
464
500
  setThreadId(null);
465
501
  setMessages([]);
466
502
  setMoreOpen(false);
503
+ setActiveView("chat");
504
+ setSwarmFocus(null);
505
+ setExpandedAgent(null);
506
+ setExpandActive(false);
507
+ setSlashIndex(0);
508
+ setSlashDismissed(false);
467
509
  }
468
510
  }, [open]);
469
511
 
@@ -478,13 +520,27 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
478
520
  return () => document.removeEventListener("pointerdown", onPointerDown);
479
521
  }, [moreOpen]);
480
522
 
481
- // Escape key
523
+ // Escape key — expand mode collapses back to the prior sidecar view first;
524
+ // a second Escape (or Escape outside expand mode) closes the sidecar as
525
+ // before. Predictable, no scroll trap.
482
526
  useEffect(() => {
483
527
  if (!open) return undefined;
484
- const handler = (e) => { if (e.key === "Escape") onClose(); };
528
+ const handler = (e) => {
529
+ if (e.key !== "Escape") return;
530
+ if (expandActive) {
531
+ e.preventDefault();
532
+ setExpandActive(false);
533
+ if (activeView === "tool-output") {
534
+ setActiveView(priorViewRef.current || "swarm-list");
535
+ setExpandedAgent(null);
536
+ }
537
+ return;
538
+ }
539
+ onClose();
540
+ };
485
541
  window.addEventListener("keydown", handler);
486
542
  return () => window.removeEventListener("keydown", handler);
487
- }, [open, onClose]);
543
+ }, [open, onClose, expandActive, activeView]);
488
544
 
489
545
  // Cmd+Enter at the window level fires apply when there is a result with
490
546
  // accepted proposals. The textarea handler stops propagation when the
@@ -541,6 +597,11 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
541
597
 
542
598
  async function runQuery() {
543
599
  if (!prompt.trim() || streaming) return;
600
+ if (!helperWidgetState.ready) {
601
+ setQueryError(helperWidgetState.guidance);
602
+ setActiveTab("chat");
603
+ return;
604
+ }
544
605
  setResult(null);
545
606
  setQueryError("");
546
607
  setStreamBuffer("");
@@ -672,6 +733,10 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
672
733
 
673
734
  const sandboxRow = resolveSandboxEnvRow(workspaceConfig);
674
735
  const helperAgentConfigured = isHelperConfigured(workspaceConfig);
736
+ const helperWidgetState = useMemo(
737
+ () => deriveHelperWidgetCausationState(workspaceConfig),
738
+ [workspaceConfig]
739
+ );
675
740
  const liveModel = sandboxRow?.localModel || "";
676
741
  const liveEndpoint = sandboxRow?.localEndpoint || "";
677
742
  const liveAdapter = sandboxRow?.intelligenceAdapterMode || "ollama";
@@ -818,6 +883,106 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
818
883
  try { promptRef.current?.focus(); } catch {}
819
884
  };
820
885
 
886
+ // Slash menu state derives from the live prompt. The menu only engages
887
+ // when "/" is the first character (parseSlashInput), never mid-sentence.
888
+ const slash = slashDismissed ? { active: false, query: "", matches: [] } : parseSlashInput(prompt);
889
+ const slashActive = slash.active && slash.matches.length > 0 && !streaming && activeView === "chat";
890
+
891
+ // Selecting a command never mutates config: read-only commands switch the
892
+ // sidecar view or seed a prompt; mutating commands seed a governed
893
+ // proposal request (intent + template) that still travels query → review
894
+ // → apply. No command calls sandbox-run or PATCH directly.
895
+ const selectSlashCommand = (cmd) => {
896
+ if (!helperWidgetState.ready) {
897
+ setQueryError(helperWidgetState.guidance);
898
+ setActiveTab("chat");
899
+ return;
900
+ }
901
+ setSlashDismissed(false);
902
+ setSlashIndex(0);
903
+ if (cmd.view) {
904
+ setPrompt("");
905
+ setSwarmFocus(null);
906
+ setActiveView(cmd.view);
907
+ return;
908
+ }
909
+ if (cmd.intent) onPickIntent(cmd.intent);
910
+ setPrompt(cmd.promptTemplate ? `${cmd.promptTemplate} ` : "");
911
+ try { promptRef.current?.focus(); } catch {}
912
+ };
913
+
914
+ const handleSlashKeyDown = (e) => {
915
+ if (!slashActive) return false;
916
+ if (e.key === "ArrowDown") {
917
+ e.preventDefault();
918
+ setSlashIndex((i) => (i + 1) % slash.matches.length);
919
+ return true;
920
+ }
921
+ if (e.key === "ArrowUp") {
922
+ e.preventDefault();
923
+ setSlashIndex((i) => (i - 1 + slash.matches.length) % slash.matches.length);
924
+ return true;
925
+ }
926
+ if (e.key === "Enter" || e.key === "Tab") {
927
+ e.preventDefault();
928
+ e.stopPropagation();
929
+ selectSlashCommand(slash.matches[Math.min(slashIndex, slash.matches.length - 1)]);
930
+ return true;
931
+ }
932
+ if (e.key === "Escape") {
933
+ e.preventDefault();
934
+ e.stopPropagation();
935
+ setSlashDismissed(true);
936
+ return true;
937
+ }
938
+ return false;
939
+ };
940
+
941
+ // Refresh the page-level workspace config after a sandbox-run stamps run
942
+ // state onto a row — same onApplied channel the apply flow already uses.
943
+ const refreshWorkspaceConfig = async () => {
944
+ try {
945
+ const res = await fetch("/api/workspace");
946
+ const data = await res.json();
947
+ if (data?.workspaceConfig && onApplied) onApplied(data.workspaceConfig);
948
+ } catch {
949
+ // Non-fatal — the cockpit's own history polling still reflects the run.
950
+ }
951
+ };
952
+
953
+ // Open a swarm artifact (apply receipt → Open) inside the sidecar cockpit;
954
+ // every other artifact surface keeps routing through the page-level handler.
955
+ const handleOpenArtifact = (target, proposal) => {
956
+ if (target?.surface === "swarm-run") {
957
+ setSwarmFocus({ objectId: target.objectId, name: target.name });
958
+ setActiveView("swarm-detail");
959
+ return;
960
+ }
961
+ if (typeof onOpenArtifact === "function") onOpenArtifact(target, proposal);
962
+ };
963
+
964
+ const handleExpandTranscript = (agent) => {
965
+ priorViewRef.current = activeView === "tool-output" ? priorViewRef.current : activeView;
966
+ setExpandedAgent(agent);
967
+ setActiveView("tool-output");
968
+ setExpandActive(true);
969
+ };
970
+
971
+ const collapseExpand = () => {
972
+ setExpandActive(false);
973
+ setActiveView(priorViewRef.current || "swarm-list");
974
+ setExpandedAgent(null);
975
+ };
976
+
977
+ const inSwarmView = activeView === "swarm-list" || activeView === "swarm-detail" || activeView === "tool-output";
978
+ const canOpenSwarmWorkflow = Boolean(
979
+ inSwarmView
980
+ && activeTab === "assistant"
981
+ && swarmFocus?.objectId
982
+ && swarmFocus?.name
983
+ && typeof onOpenSwarmWorkflow === "function"
984
+ );
985
+
821
986
  if (!open) return null;
822
987
 
823
988
  return (
@@ -830,7 +995,7 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
830
995
  role="dialog"
831
996
  aria-label="Workspace helper"
832
997
  aria-modal="true"
833
- style={{ width: panelWidth }}
998
+ style={{ width: expandActive ? "80vw" : panelWidth }}
834
999
  >
835
1000
  {/* Drag handle */}
836
1001
  <div
@@ -844,22 +1009,50 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
844
1009
  {/* Header — title left; gear toggles Assistant ↔ Setup, then close. */}
845
1010
  <div className="dm-sidecar-header">
846
1011
  <div className="dm-sidecar-header-left">
1012
+ {inSwarmView && (
1013
+ <button
1014
+ type="button"
1015
+ className="dm-sidecar-icon-btn"
1016
+ onClick={() => {
1017
+ if (activeView === "tool-output") { collapseExpand(); return; }
1018
+ setActiveView("chat");
1019
+ setSwarmFocus(null);
1020
+ }}
1021
+ aria-label="Back to chat"
1022
+ title="Back to chat"
1023
+ data-swarm-back=""
1024
+ >
1025
+ <ArrowLeft size={14} />
1026
+ </button>
1027
+ )}
847
1028
  <span className="dm-sidecar-title" data-helper-title="">
848
- {threadActive
849
- ? deriveThreadDisplayTitle(initialThread, "Workspace Helper")
850
- : "Workspace Helper"}
1029
+ {inSwarmView
1030
+ ? "Background tasks"
1031
+ : threadActive
1032
+ ? deriveThreadDisplayTitle(initialThread, "Workspace Helper")
1033
+ : "Workspace Helper"}
851
1034
  </span>
852
1035
  </div>
853
1036
  <div className="dm-sidecar-header-right">
854
1037
  <button
855
1038
  type="button"
856
1039
  className="dm-sidecar-icon-btn"
857
- onClick={() => setActiveTab((current) => (current === "setup" ? "assistant" : "setup"))}
858
- aria-label={activeTab === "setup" ? "Back" : "Setup"}
859
- title={activeTab === "setup" ? "Back" : "Setup"}
1040
+ onClick={() => {
1041
+ if (canOpenSwarmWorkflow) {
1042
+ onOpenSwarmWorkflow(swarmFocus);
1043
+ onClose?.();
1044
+ return;
1045
+ }
1046
+ setActiveTab((current) => (current === "setup" ? "assistant" : "setup"));
1047
+ }}
1048
+ disabled={inSwarmView && activeTab === "assistant" && !canOpenSwarmWorkflow}
1049
+ aria-label={inSwarmView && activeTab === "assistant" ? "Open workflow canvas" : activeTab === "setup" ? "Back" : "Setup"}
1050
+ title={inSwarmView && activeTab === "assistant" ? "Open workflow canvas" : activeTab === "setup" ? "Back" : "Setup"}
860
1051
  data-tab={activeTab === "setup" ? "assistant" : "setup"}
861
1052
  >
862
- {activeTab === "setup" ? <ArrowLeft size={14} /> : <Settings size={14} />}
1053
+ {inSwarmView && activeTab === "assistant"
1054
+ ? <ArrowUpRight size={14} />
1055
+ : activeTab === "setup" ? <ArrowLeft size={14} /> : <Settings size={14} />}
863
1056
  </button>
864
1057
  <button
865
1058
  type="button"
@@ -873,11 +1066,31 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
873
1066
  </div>
874
1067
  </div>
875
1068
 
1069
+ {/* Swarm cockpit views — Background tasks list / detail / expanded
1070
+ transcript. Same sidecar shell, no route change; chat state stays
1071
+ mounted-adjacent and untouched while the user inspects runs. */}
1072
+ {activeTab === "assistant" && inSwarmView && (
1073
+ <div className="dm-sidecar-body dm-swarm-body" data-swarm-view={activeView}>
1074
+ {activeView === "tool-output" && expandedAgent ? (
1075
+ <SidecarExpandView title={expandedAgent.label || "Transcript"} onBack={collapseExpand}>
1076
+ <SwarmAgentTranscript agent={expandedAgent} />
1077
+ </SidecarExpandView>
1078
+ ) : (
1079
+ <SwarmRunCockpit
1080
+ workspaceConfig={workspaceConfig}
1081
+ focus={activeView === "swarm-detail" ? swarmFocus : null}
1082
+ onConfigRefresh={refreshWorkspaceConfig}
1083
+ onExpandTranscript={handleExpandTranscript}
1084
+ />
1085
+ )}
1086
+ </div>
1087
+ )}
1088
+
876
1089
  {/* Assistant tab — composer-at-bottom layout (Twenty Ask AI parity):
877
1090
  conversation/result area on top (flex:1), bottom-anchored composer
878
1091
  holds chip stack (empty state) → mode row (active thread) →
879
1092
  textarea with attach + mode + send-arrow action row. */}
880
- {activeTab === "assistant" && (
1093
+ {activeTab === "assistant" && !inSwarmView && (
881
1094
  <div className="dm-sidecar-body dm-helper-body">
882
1095
  <div className="dm-helper-conversation" ref={conversationRef}>
883
1096
  {/* Conversation — ChatGPT-grade multi-turn. User bubble
@@ -911,11 +1124,7 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
911
1124
  <ToolCallCard
912
1125
  proposal={receipt}
913
1126
  content={m.content || ""}
914
- onOpenArtifact={(p) => {
915
- if (typeof onOpenArtifact === "function") {
916
- onOpenArtifact(resolveArtifactTarget(p), p);
917
- }
918
- }}
1127
+ onOpenArtifact={(p) => handleOpenArtifact(resolveArtifactTarget(p), p)}
919
1128
  />
920
1129
  </div>
921
1130
  );
@@ -1154,20 +1363,53 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
1154
1363
  ) : null}
1155
1364
 
1156
1365
  <div className="dm-helper-composer-input">
1366
+ {!helperWidgetState.ready && (
1367
+ <div className="dm-helper-error" role="status">
1368
+ <span>{helperWidgetState.guidance}</span>
1369
+ </div>
1370
+ )}
1371
+ {slashActive && (
1372
+ <div className="dm-helper-pill-menu dm-helper-slash-menu" role="listbox" data-helper-slash-menu="">
1373
+ {slash.matches.map((cmd, i) => (
1374
+ <button
1375
+ key={cmd.name}
1376
+ type="button"
1377
+ className={`dm-helper-pill-menu-item${i === Math.min(slashIndex, slash.matches.length - 1) ? " active" : ""}`}
1378
+ role="option"
1379
+ aria-selected={i === slashIndex}
1380
+ data-helper-slash-item={cmd.name}
1381
+ disabled={!helperWidgetState.ready}
1382
+ title={!helperWidgetState.ready ? helperWidgetState.guidance : cmd.description || cmd.label}
1383
+ onClick={() => selectSlashCommand(cmd)}
1384
+ onMouseEnter={() => setSlashIndex(i)}
1385
+ >
1386
+ <span className="dm-helper-slash-name">{cmd.name}</span>
1387
+ <span className="dm-field-hint">{cmd.description || cmd.label}</span>
1388
+ </button>
1389
+ ))}
1390
+ </div>
1391
+ )}
1157
1392
  <textarea
1158
1393
  id="helper-prompt"
1159
1394
  ref={promptRef}
1160
1395
  className="dm-helper-composer-textarea"
1161
1396
  rows={threadActive ? 2 : 3}
1162
1397
  placeholder={threadActive
1163
- ? 'Continue the conversation…'
1164
- : 'Ask, search or make anything…'}
1398
+ ? 'Continue, or type / for commands…'
1399
+ : 'Type / for commands, or ask anything…'}
1165
1400
  value={prompt}
1166
- onChange={(e) => setPrompt(e.target.value)}
1401
+ onChange={(e) => {
1402
+ setPrompt(e.target.value);
1403
+ setSlashDismissed(false);
1404
+ setSlashIndex(0);
1405
+ }}
1167
1406
  disabled={streaming}
1168
1407
  data-helper-prompt=""
1169
1408
  aria-label="Helper prompt"
1170
1409
  onKeyDown={(e) => {
1410
+ // Slash menu intercepts navigation/selection keys while
1411
+ // open; Cmd+Enter submission below stays intact.
1412
+ if (handleSlashKeyDown(e)) return;
1171
1413
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
1172
1414
  e.preventDefault();
1173
1415
  // Stop the window-level apply handler from firing on the
@@ -1192,7 +1434,7 @@ export function HelperSidecar({ open, onClose, workspaceConfig, initialIntent, i
1192
1434
  type="button"
1193
1435
  className="dm-helper-composer-send"
1194
1436
  onClick={runQuery}
1195
- disabled={streaming || !prompt.trim()}
1437
+ disabled={streaming || !prompt.trim() || !helperWidgetState.ready}
1196
1438
  data-helper-submit=""
1197
1439
  aria-label={streaming ? "Sending" : "Send (⌘+Enter)"}
1198
1440
  title={streaming ? "Sending…" : `Send · ${intentLabel(activeIntent)} (⌘+Enter)`}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useState } from "react";
3
+ import { useCallback, useMemo, useRef, useState } from "react";
4
4
  import {
5
5
  ArrowDownToLine,
6
6
  Bot,
@@ -53,6 +53,15 @@ const CONNECTOR_OPTIONS = [
53
53
  { id: "preview", label: "Preview output" }
54
54
  ];
55
55
 
56
+ const MIN_ZOOM = 0.45;
57
+ const MAX_ZOOM = 1.4;
58
+ const NODE_BLOCK_HEIGHT = 98;
59
+ const FIT_VIEW_PADDING = 128;
60
+
61
+ function clampZoom(value) {
62
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))));
63
+ }
64
+
56
65
  function nodeSubtitle(node) {
57
66
  const config = node?.config || {};
58
67
  if (node?.subtitle) return String(node.subtitle);
@@ -111,8 +120,61 @@ export function OrchestrationGraphCanvas({
111
120
  const [internalSelected, setInternalSelected] = useState(null);
112
121
  const [connectorPopover, setConnectorPopover] = useState(null);
113
122
  const [zoom, setZoom] = useState(1);
123
+ const [pan, setPan] = useState({ x: 0, y: 0 });
124
+ const canvasRef = useRef(null);
125
+ const dragRef = useRef(null);
114
126
  const activeId = selectedNodeId ?? internalSelected;
115
127
 
128
+ function edgeBetween(fromId, toId) {
129
+ return edges.find((e) => String(e.from) === fromId && String(e.to) === toId);
130
+ }
131
+
132
+ const zoomBy = useCallback((delta) => {
133
+ setZoom((value) => clampZoom(value + delta));
134
+ }, []);
135
+
136
+ const fitView = useCallback(() => {
137
+ const rect = canvasRef.current?.getBoundingClientRect();
138
+ const availableHeight = Math.max(240, (rect?.height || 720) - FIT_VIEW_PADDING);
139
+ const graphHeight = Math.max(NODE_BLOCK_HEIGHT, nodes.length * NODE_BLOCK_HEIGHT);
140
+ const nextZoom = clampZoom(Math.min(1, availableHeight / graphHeight));
141
+ setZoom(nextZoom);
142
+ setPan({ x: 0, y: 0 });
143
+ }, [nodes.length]);
144
+
145
+ const handleWheel = useCallback((event) => {
146
+ event.preventDefault();
147
+ const direction = event.deltaY > 0 ? -0.08 : 0.08;
148
+ zoomBy(direction);
149
+ }, [zoomBy]);
150
+
151
+ const handlePointerDown = useCallback((event) => {
152
+ if (event.button !== 0) return;
153
+ if (event.target.closest("button, .dm-orchestration-node, .dm-orchestration-connector__popover")) return;
154
+ dragRef.current = {
155
+ pointerId: event.pointerId,
156
+ startX: event.clientX,
157
+ startY: event.clientY,
158
+ originX: pan.x,
159
+ originY: pan.y
160
+ };
161
+ event.currentTarget.setPointerCapture?.(event.pointerId);
162
+ }, [pan.x, pan.y]);
163
+
164
+ const handlePointerMove = useCallback((event) => {
165
+ const drag = dragRef.current;
166
+ if (!drag || drag.pointerId !== event.pointerId) return;
167
+ event.preventDefault();
168
+ setPan({
169
+ x: drag.originX + event.clientX - drag.startX,
170
+ y: drag.originY + event.clientY - drag.startY
171
+ });
172
+ }, []);
173
+
174
+ const endDrag = useCallback((event) => {
175
+ if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
176
+ }, []);
177
+
116
178
  if (!nodes.length) {
117
179
  return (
118
180
  <div className="dm-orchestration-canvas dm-orchestration-canvas--empty">
@@ -121,12 +183,18 @@ export function OrchestrationGraphCanvas({
121
183
  );
122
184
  }
123
185
 
124
- function edgeBetween(fromId, toId) {
125
- return edges.find((e) => String(e.from) === fromId && String(e.to) === toId);
126
- }
127
-
128
186
  return (
129
- <div className="dm-orchestration-canvas" aria-label="Orchestration graph field editor">
187
+ <div
188
+ ref={canvasRef}
189
+ className="dm-orchestration-canvas"
190
+ aria-label="Orchestration graph field editor"
191
+ onWheel={handleWheel}
192
+ onPointerDown={handlePointerDown}
193
+ onPointerMove={handlePointerMove}
194
+ onPointerUp={endDrag}
195
+ onPointerCancel={endDrag}
196
+ onPointerLeave={endDrag}
197
+ >
130
198
  <span className={`dm-orchestration-canvas__badge is-${String(statusLabel || "draft").toLowerCase()}`}>{statusLabel}</span>
131
199
  <div className="dm-orchestration-floating-tools" aria-label="Canvas tools">
132
200
  <button type="button" title="Add node" aria-label="Add node" onClick={() => onConnectorAction?.({ action: "add-step", from: String(nodes[nodes.length - 1]?.id || ""), to: "" })}>
@@ -135,17 +203,20 @@ export function OrchestrationGraphCanvas({
135
203
  <button type="button" title="Tidy workflow" aria-label="Tidy workflow">
136
204
  <Settings size={14} />
137
205
  </button>
138
- <button type="button" title="Zoom in" aria-label="Zoom in" onClick={() => setZoom((value) => Math.min(1.4, Number((value + 0.1).toFixed(2))))}>
206
+ <button type="button" title="Zoom in" aria-label="Zoom in" onClick={() => zoomBy(0.1)}>
139
207
  <ZoomIn size={14} />
140
208
  </button>
141
- <button type="button" title="Zoom out" aria-label="Zoom out" onClick={() => setZoom((value) => Math.max(0.7, Number((value - 0.1).toFixed(2))))}>
209
+ <button type="button" title="Zoom out" aria-label="Zoom out" onClick={() => zoomBy(-0.1)}>
142
210
  <ZoomOut size={14} />
143
211
  </button>
144
- <button type="button" title="Reset zoom" aria-label="Reset zoom" onClick={() => setZoom(1)}>
212
+ <button type="button" title="Fit view" aria-label="Fit view" onClick={fitView}>
145
213
  <Maximize2 size={14} />
146
214
  </button>
147
215
  </div>
148
- <div className="dm-orchestration-canvas__viewport" style={{ transform: `scale(${zoom})` }}>
216
+ <div
217
+ className="dm-orchestration-canvas__viewport"
218
+ style={{ transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})` }}
219
+ >
149
220
  {nodes.map((node, index) => {
150
221
  const id = String(node.id || "");
151
222
  const isSelected = activeId === id;