@arcote.tech/arc-process 0.7.28 → 0.7.29

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.
@@ -44,10 +44,12 @@ import { MdxView, parseProcessFrontmatter } from "../mdx/mdx-view";
44
44
  import {
45
45
  FocusContext,
46
46
  ProcessDataContext,
47
+ SelectionContext,
47
48
  ViewStateContext,
48
49
  } from "../mdx/contexts";
49
50
  import { makeNodeTypes } from "./nodes";
50
- import { GLASS, MISSING_COLOR } from "./theme";
51
+ import { ProcessDrawer, type SourceProvider } from "./drawer";
52
+ import { GLASS } from "./theme";
51
53
 
52
54
  export interface ProcessDiagramProps {
53
55
  /** Surowy MDX procesu. */
@@ -70,6 +72,8 @@ export interface ProcessDiagramProps {
70
72
  defaultCollapsed?: boolean;
71
73
  /** Id/nazwa kontekstu domowego (fallback: frontmatter `context:`). */
72
74
  homeContextId?: string;
75
+ /** Dostawca kodu źródłowego do drawera (np. fetch /api/source w arc-map). */
76
+ sourceProvider?: SourceProvider;
73
77
  }
74
78
 
75
79
  function rfNode(n: DiagramNode): Node {
@@ -115,11 +119,13 @@ export function ProcessDiagram({
115
119
  title,
116
120
  defaultCollapsed,
117
121
  homeContextId,
122
+ sourceProvider,
118
123
  }: ProcessDiagramProps) {
119
124
  const [occurrences, setOccurrences] = useState<Occurrence[]>([]);
120
125
  const [viewState, setViewState] = useState<ProcessViewState | null>(null);
121
126
  const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
122
127
  const [scrollToKey, setScrollToKey] = useState<string | null>(null);
128
+ const [selectedOcc, setSelectedOcc] = useState<string | null>(null);
123
129
  const proseRef = useRef<HTMLDivElement>(null);
124
130
 
125
131
  const frontmatter = useMemo(() => parseProcessFrontmatter(mdx), [mdx]);
@@ -132,6 +138,7 @@ export function ProcessDiagram({
132
138
  useEffect(() => {
133
139
  setOccurrences([]);
134
140
  setViewState(null);
141
+ setSelectedOcc(null);
135
142
  }, [mdx]);
136
143
 
137
144
  const model = useMemo(
@@ -218,15 +225,35 @@ export function ProcessDiagram({
218
225
 
219
226
  const processData = useMemo(() => ({ graph, profile }), [graph, profile]);
220
227
 
228
+ const selectionState = useMemo(
229
+ () => ({ selectedOcc, selectOcc: setSelectedOcc }),
230
+ [selectedOcc],
231
+ );
232
+
233
+ // Krok do drawera: wybrany occId szukamy najpierw wśród kroków po collapse
234
+ // (pseudo-kroki boxów), potem w oryginalnych (chip prozy członka boxa).
235
+ const selectedStep = useMemo(() => {
236
+ if (!selectedOcc) return null;
237
+ const group = collapse.groupByOcc.get(selectedOcc);
238
+ return (
239
+ collapse.steps.find((s) => s.occId === (group ?? selectedOcc)) ??
240
+ model.steps.find((s) => s.occId === selectedOcc) ??
241
+ null
242
+ );
243
+ }, [selectedOcc, collapse, model]);
244
+
221
245
  return (
222
246
  <ProcessDataContext.Provider value={processData}>
223
247
  <FocusContext.Provider value={focusState}>
248
+ <SelectionContext.Provider value={selectionState}>
224
249
  <ViewStateContext.Provider value={viewStateActions}>
225
250
  <div
226
251
  style={{
227
252
  display: "grid",
228
253
  gridTemplateColumns: "minmax(320px, 1fr) minmax(420px, 1.3fr)",
229
254
  height: "100%",
255
+ minHeight: 0,
256
+ overflow: "hidden",
230
257
  }}
231
258
  >
232
259
  {/* Proza */}
@@ -234,6 +261,7 @@ export function ProcessDiagram({
234
261
  ref={proseRef}
235
262
  style={{
236
263
  overflow: "auto",
264
+ minHeight: 0,
237
265
  padding: "20px 24px",
238
266
  borderRight: `1px solid ${GLASS.border}`,
239
267
  }}
@@ -241,8 +269,8 @@ export function ProcessDiagram({
241
269
  <MdxView key={docKey} mdx={mdx} onOccurrences={setOccurrences} />
242
270
  </div>
243
271
 
244
- {/* Diagram */}
245
- <div style={{ position: "relative" }}>
272
+ {/* Diagram — sztywno wypełnia dostępną wysokość, bez scrolla strony */}
273
+ <div style={{ position: "relative", minHeight: 0, overflow: "hidden" }}>
246
274
  <ReactFlow
247
275
  key={docKey}
248
276
  nodes={nodes}
@@ -260,7 +288,10 @@ export function ProcessDiagram({
260
288
  onNodeClick={(_, n) => {
261
289
  const k = keyByOcc.get(n.id);
262
290
  if (k) setScrollToKey(k);
291
+ // Kroki (także boxy kontekstów) otwierają drawer.
292
+ if (n.type === "step") setSelectedOcc(n.id);
263
293
  }}
294
+ onPaneClick={() => setSelectedOcc(null)}
264
295
  fitView
265
296
  fitViewOptions={{ padding: 0.18 }}
266
297
  minZoom={0.1}
@@ -273,9 +304,10 @@ export function ProcessDiagram({
273
304
  zoomable
274
305
  nodeColor={(n) => {
275
306
  const step = (n.data as { step?: ProcessStep }).step;
276
- if (step && !step.exists) return MISSING_COLOR;
307
+ // Kolor z zadeklarowanego rodzaju — także dla TODO.
277
308
  const kind =
278
309
  step?.node?.kind ??
310
+ step?.ref?.kind ??
279
311
  (n.data as { node?: { kind: string } }).node?.kind;
280
312
  return kind ? kindColor(profile, kind) : "#64748b";
281
313
  }}
@@ -286,9 +318,20 @@ export function ProcessDiagram({
286
318
  }}
287
319
  />
288
320
  </ReactFlow>
321
+
322
+ {/* Drawer szczegółów wybranego kroku. */}
323
+ {selectedStep ? (
324
+ <ProcessDrawer
325
+ step={selectedStep}
326
+ profile={profile}
327
+ onClose={() => setSelectedOcc(null)}
328
+ sourceProvider={sourceProvider}
329
+ />
330
+ ) : null}
289
331
  </div>
290
332
  </div>
291
333
  </ViewStateContext.Provider>
334
+ </SelectionContext.Provider>
292
335
  </FocusContext.Provider>
293
336
  </ProcessDataContext.Provider>
294
337
  );
@@ -239,10 +239,13 @@ describe("buildProcessModel — fazy, Entry/Continue, notatki, konteksty", () =>
239
239
  const m = buildProcessModel({ graph, profile: arcProfile, occurrences });
240
240
  const by = new Map(m.steps.map((s) => [s.occId, s]));
241
241
 
242
- // Oś główna zaczyna od depth 0.
243
- expect([by.get("m1")!.depth, by.get("m1")!.lane]).toEqual([0, 0]);
244
- expect([by.get("m2")!.depth, by.get("m2")!.lane]).toEqual([1, 0]);
245
- // Entry (łańcuch L=2, cel depth 0) → start = max(0, 0-2) = 0, tor nad osią.
242
+ // Oś główna przesunięta o wyprzedzenie wejścia (maxLead = 2 − 0 = 2),
243
+ // by łańcuch Entry zmieścił się PRZED fazą — continues idzie w prawo.
244
+ expect([by.get("m1")!.depth, by.get("m1")!.lane]).toEqual([2, 0]);
245
+ expect([by.get("m2")!.depth, by.get("m2")!.lane]).toEqual([3, 0]);
246
+ // Entry (łańcuch L=2, cel depth 2) → depths 0,1 — koniec kolumnę przed fazą.
247
+ expect(by.get("e1")!.depth).toBe(0);
248
+ expect(by.get("e2")!.depth).toBe(1);
246
249
  expect(by.get("e1")!.lane).toBeLessThan(0);
247
250
  expect(by.get("e2")!.lane).toBe(by.get("e1")!.lane);
248
251
  expect(by.get("e1")!.entryTitle).toBe("Formularz");
@@ -294,6 +297,46 @@ describe("buildProcessModel — fazy, Entry/Continue, notatki, konteksty", () =>
294
297
  expect(m.continueEdges).toEqual([{ fromOcc: "f1", toOcc: "m1", phaseId: "" }]);
295
298
  });
296
299
 
300
+ test("maxLead: wejście w środek nie przesuwa osi, gdy faza jest wystarczająco głęboko", () => {
301
+ const occurrences: Occurrence[] = [
302
+ occ("m1", { view: "v" }, "m1", phasePath("start")),
303
+ occ("m2", { view: "x" }, "m2", phasePath("wywiad")),
304
+ occ("a1", { command: "cmd" }, "a1", entryPath("en-a", "Admin")),
305
+ { type: "continue", to: "wywiad", path: entryPath("en-a", "Admin") },
306
+ ];
307
+ const m = buildProcessModel({ graph, profile: arcProfile, occurrences });
308
+ const by = new Map(m.steps.map((s) => [s.occId, s]));
309
+ // Faza wywiad na depth 1, łańcuch L=1 → lead 0 → oś bez przesunięcia.
310
+ expect(by.get("m1")!.depth).toBe(0);
311
+ expect(by.get("m2")!.depth).toBe(1);
312
+ expect(by.get("a1")!.depth).toBe(0); // kolumnę przed fazą wywiad
313
+ });
314
+
315
+ test("tory wejść: cel dalej w prawo = bliżej osi (mniej przecięć)", () => {
316
+ const occurrences: Occurrence[] = [
317
+ // Dwa wejścia NAD osią (indeksy 0 i 2 w kolejności dokumentu).
318
+ occ("f1", { command: "cmd" }, "f1", entryPath("en-a", "A")),
319
+ { type: "continue", to: "p1", path: entryPath("en-a", "A") },
320
+ occ("g1", { listener: "l" }, "g1", entryPath("en-b", "B")),
321
+ { type: "continue", to: "p2", path: entryPath("en-b", "B") },
322
+ occ("h1", { view: "x" }, "h1", entryPath("en-c", "C")),
323
+ { type: "continue", to: "p1", path: entryPath("en-c", "C") },
324
+ occ("m1", { view: "v" }, "m1", phasePath("p1")),
325
+ occ("m2", { event: "e" }, "m2", phasePath("p2")),
326
+ ];
327
+ const m = buildProcessModel({ graph, profile: arcProfile, occurrences });
328
+ const by = new Map(m.steps.map((s) => [s.occId, s]));
329
+ // Nad osią: f1 (cel p1) i h1 (cel p1)... f1(k=0) i h1(k=2) nad osią,
330
+ // g1(k=1) pod osią. Nad osią cel głębszy → tor bliższy osi.
331
+ expect(by.get("g1")!.lane).toBeGreaterThan(0);
332
+ const f1 = by.get("f1")!;
333
+ const h1 = by.get("h1")!;
334
+ // Oba celują w p1 — kolejność stabilna, oba nad osią na różnych torach.
335
+ expect(f1.lane).toBeLessThan(0);
336
+ expect(h1.lane).toBeLessThan(0);
337
+ expect(f1.lane).not.toBe(h1.lane);
338
+ });
339
+
297
340
  test("notatka dokleja się do poprzedzającego kroku", () => {
298
341
  const occurrences: Occurrence[] = [
299
342
  occ("m1", { view: "v" }, "m1"),
@@ -332,6 +375,57 @@ describe("buildProcessModel — fazy, Entry/Continue, notatki, konteksty", () =>
332
375
  expect(m.steps[0].node?.contextPath).toEqual(["ctx_ai", "ctx_chat"]);
333
376
  });
334
377
 
378
+ test("piny rozwiązują się z katalogu (catalogKind, label, pierwszeństwo MDX)", () => {
379
+ const g = toProcessGraph({
380
+ elements: [el("cmd", "command")],
381
+ edges: [],
382
+ catalog: [
383
+ {
384
+ kind: "personas",
385
+ name: "prospekt",
386
+ data: { name: "prospekt", label: "Prospekt", color: "#0e7490" },
387
+ },
388
+ {
389
+ kind: "metrics",
390
+ name: "leads.submitted",
391
+ data: { name: "leads.submitted", label: "Zgłoszone leady" },
392
+ },
393
+ ],
394
+ });
395
+ const o: StepOccurrence = {
396
+ ...occ("o1", { command: "cmd" }),
397
+ pins: [
398
+ { kind: "persona", name: "prospekt" },
399
+ { kind: "metric", name: "leads.submitted", label: "Leady (MDX)" },
400
+ { kind: "persona", name: "nieznana" },
401
+ ],
402
+ };
403
+ const m = buildProcessModel({ graph: g, profile: arcProfile, occurrences: [o] });
404
+ expect(m.steps[0].pins).toEqual([
405
+ {
406
+ kind: "persona",
407
+ name: "prospekt",
408
+ label: "Prospekt",
409
+ data: { name: "prospekt", label: "Prospekt", color: "#0e7490" },
410
+ resolved: true,
411
+ },
412
+ {
413
+ kind: "metric",
414
+ name: "leads.submitted",
415
+ label: "Leady (MDX)", // etykieta z MDX wygrywa nad katalogiem
416
+ data: { name: "leads.submitted", label: "Zgłoszone leady" },
417
+ resolved: true,
418
+ },
419
+ {
420
+ kind: "persona",
421
+ name: "nieznana",
422
+ label: "nieznana",
423
+ data: undefined,
424
+ resolved: false,
425
+ },
426
+ ]);
427
+ });
428
+
335
429
  test("sides/expand z wystąpienia przechodzą na krok", () => {
336
430
  const o: StepOccurrence = {
337
431
  ...occ("o1", { command: "cmd" }),
@@ -134,6 +134,40 @@ describe("layoutProcess — tracki i kierunki", () => {
134
134
  expect(edge).toBeDefined();
135
135
  });
136
136
 
137
+ test("fork/join: blok Parallel dostaje rozwidlenie i scalenie", () => {
138
+ const occurrences: Occurrence[] = [
139
+ occ("s1", { command: "cmd" }),
140
+ { ...occ("p1", { view: "v" }), path: [{ type: "parallel", id: "P", laneIndex: 0 }] },
141
+ { ...occ("p2", { event: "e" }), path: [{ type: "parallel", id: "P", laneIndex: 1 }] },
142
+ occ("s2", { listener: "l" }),
143
+ ];
144
+ const { layout } = run(occurrences);
145
+ const bb = layout.edges
146
+ .filter((e) => e.backbone)
147
+ .map((e) => `${e.source}->${e.target}`);
148
+ // Fork: s1 → oba lane'y; join: oba lane'y → s2.
149
+ expect(bb).toContain("s1->p1");
150
+ expect(bb).toContain("s1->p2");
151
+ expect(bb).toContain("p1->s2");
152
+ expect(bb).toContain("p2->s2");
153
+ });
154
+
155
+ test("szkielet osi głównej nie przerywa się na bloku Entry w środku dokumentu", () => {
156
+ const occurrences: Occurrence[] = [
157
+ { ...occ("m1", { command: "cmd" }), path: [{ type: "step", id: "sA", phaseId: "a" }] },
158
+ // Entry wtrącone w dokument między fazami.
159
+ { ...occ("e1", { view: "v" }), path: [{ type: "entry", id: "en", title: "E" }] },
160
+ { type: "continue", to: "b", path: [{ type: "entry", id: "en" }] },
161
+ { ...occ("m2", { event: "e" }), path: [{ type: "step", id: "sB", phaseId: "b" }] },
162
+ ];
163
+ const { layout } = run(occurrences);
164
+ const bb = layout.edges
165
+ .filter((e) => e.backbone)
166
+ .map((e) => `${e.source}->${e.target}`);
167
+ expect(bb).toContain("m1->m2"); // oś główna ciągła mimo Entry pomiędzy
168
+ expect(bb).not.toContain("e1->m2"); // Entry łączy się przez continues, nie szkielet
169
+ });
170
+
137
171
  test("Entry na torze ujemnym renderuje się nad osią + krawędź continues", () => {
138
172
  const occurrences: Occurrence[] = [
139
173
  {