yadflow 2.11.0 → 2.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/CHANGELOG.md CHANGED
@@ -1,9 +1,14 @@
1
- # [2.11.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.10.0...v2.11.0) (2026-06-15)
1
+ # [2.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.11.1...v2.12.0) (2026-06-16)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **checks:** harden spec-link + gitlab gate templates ([#69](https://github.com/abdelrahmannasr/yadflow/issues/69)) ([42f3949](https://github.com/abdelrahmannasr/yadflow/commit/42f3949841095624db03cb17f85db3138be8b93b))
2
7
 
3
8
 
4
9
  ### Features
5
10
 
6
- * add yad-sync-repos switch every connected repo to its default branch + ff pull ([#67](https://github.com/abdelrahmannasr/yadflow/issues/67)) ([0abdcdf](https://github.com/abdelrahmannasr/yadflow/commit/0abdcdf80c8a0a6bfd8c94129fde90f1d35ec365)), closes [#30](https://github.com/abdelrahmannasr/yadflow/issues/30)
11
+ * **docs:** pipeline-shaped overview canvas + collapsible panels + content refresh ([#70](https://github.com/abdelrahmannasr/yadflow/issues/70)) ([da47b80](https://github.com/abdelrahmannasr/yadflow/commit/da47b8050ccd7aa1919bf4b364298a90cdd56012))
7
12
 
8
13
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
14
 
package/cli/doctor.mjs CHANGED
@@ -8,7 +8,7 @@ import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSO
8
8
  import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
9
9
  import { loadLedger, epicRoot } from './epic-state.mjs';
10
10
  import { gitHead } from './setup.mjs';
11
- import { cliFor, validateLogin } from './platform.mjs';
11
+ import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
12
12
 
13
13
  const MIN_NODE = 18;
14
14
 
@@ -68,8 +68,13 @@ export function projectChecks(checks, root) {
68
68
  // platform CLI + auth (best-effort; auth probing is the user's own session)
69
69
  const cli = cliFor(hub.platform);
70
70
  if (cli) {
71
+ // Scope the auth probe to the hub's own host (derived from git_url). `${cli} auth status`
72
+ // without --hostname exits non-zero when ANY configured instance fails, so an unrelated
73
+ // stale login (e.g. a dead gitlab.com token) would falsely flag a working self-hosted hub.
74
+ const host = hostFromGitUrl(hub.git_url);
75
+ const authArgs = host ? ['auth', 'status', '--hostname', host] : ['auth', 'status'];
71
76
  if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to file-only without it`);
72
- else if (!run(cli, ['auth', 'status']).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated [YAD-ENV-002]`, `run \`${cli} auth login\``);
77
+ else if (!run(cli, authArgs).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated${host ? ` for ${host}` : ''} [YAD-ENV-002]`, `run \`${cli} auth login${host ? ` --hostname ${host}` : ''}\``);
73
78
  else {
74
79
  check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
75
80
  // Re-validate each roster login against the hub (warn-only). Skips when a login is already
package/cli/platform.mjs CHANGED
@@ -17,6 +17,23 @@ export function cliFor(platform) {
17
17
  return null;
18
18
  }
19
19
 
20
+ // Bare host from a git remote URL, for hostname-scoped CLI auth checks. Handles both the
21
+ // `https://[user@]host[:port]/...` and the scp-like `git@host:group/repo.git` forms. Returns
22
+ // null when nothing parses (caller falls back to an unscoped check).
23
+ export function hostFromGitUrl(url = '') {
24
+ if (typeof url !== 'string' || !url.trim()) return null;
25
+ const u = url.trim();
26
+ // scp-like syntax: [user@]host:path — only when there's no scheme and the colon precedes a path.
27
+ const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/);
28
+ if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) return scp[1].toLowerCase() || null;
29
+ try {
30
+ // URL needs a scheme to parse a host; ssh:// and https:// both work here.
31
+ return new URL(u).hostname.toLowerCase() || null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
20
37
  // Is the platform CLI present? (auth is the user's own; we don't probe it here.)
21
38
  export function platformReady(platform) {
22
39
  const cli = cliFor(platform);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo). A BMAD module + 30 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -18,10 +18,17 @@ repo uses. Each reads conventions established by earlier steps — it invents no
18
18
 
19
19
  ## 1. spec-link (`templates/checks/spec-link.sh`)
20
20
 
21
- - Collects the `Task:` trailers across `<base>..HEAD`.
22
- - **FAIL** if there is no `Task:` trailer (the change does not link a story/spec).
23
- - For each `Task: <story>-<task>`, strips the `-T<NN>` suffix to get `<story>` and requires
24
- `specs/<story>/link.md` to exist. **FAIL** if missing.
21
+ - Checks every non-merge commit in `<base>..HEAD` **per commit** (not aggregated across the range),
22
+ so the report names each offending commit and one bad commit never masks the rest.
23
+ - Maintenance commits are **exempt**: a Conventional-Commits subject of type `ci`, `chore`, `build`,
24
+ or `test` (optional `(scope)` / breaking `!`) **PASSes** without a link — CI wiring, dependency
25
+ bumps, and test-infra changes legitimately link no story.
26
+ - For every other commit, requires a `Task: <story>-<task>` trailer. **FAIL** if absent.
27
+ - The trailer must be a well-formed `<story>-T<NN>` id. **FAIL** on a malformed trailer (e.g.
28
+ `EP-demo-S01` with no `-T<NN>`) rather than letting it slip through the suffix-strip.
29
+ - Strips the `-T<NN>` suffix from the task to get `<story>` and requires `specs/<story>/link.md` to
30
+ exist. **FAIL** if missing.
31
+ - An empty range (no non-merge commits) **PASSes**.
25
32
  - Portable across bash 3.2 (macOS) and 4+ (no `mapfile`).
26
33
  - **Fails closed** when `<base>` can't be resolved (so a shallow clone / wrong base never PASSes blind).
27
34
 
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env bash
2
2
  # spec-link gate (Phase 3 build plan §C).
3
- # The change must link a real story/spec: every commit range under review must carry a
3
+ # Every NON-MAINTENANCE commit must link a real story/spec: it must carry a
4
4
  # `Task: <story>-<task>` trailer whose <story> resolves to a specs/<story>/link.md.
5
- # Fail if the link is missing no unlinked code reaches merge.
5
+ # Maintenance commits (ci/chore/build/test) are EXEMPTCI wiring, dependency bumps,
6
+ # and test-infra changes legitimately link no story. Checked per commit (not aggregated
7
+ # across the range), so the report names every offending commit.
6
8
  set -euo pipefail
7
9
 
8
10
  BASE="${1:-${SDLC_BASE:-origin/main}}"
@@ -14,25 +16,48 @@ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
14
16
  fi
15
17
  RANGE="${BASE}..HEAD"
16
18
 
17
- # Portable across bash 3.2 (macOS) and 4+ — no mapfile.
18
- tasks="$(git log "$RANGE" --format='%(trailers:key=Task,valueonly)' | sed '/^$/d' | sort -u)"
19
+ # Conventional-Commits types exempt from the spec-link requirement (optional (scope) and breaking !).
20
+ EXEMPT='ci|chore|build|test'
19
21
 
20
- if [ -z "$tasks" ]; then
21
- echo "FAIL [spec-link]: no 'Task: <story>-<task>' trailer in ${RANGE} — change does not link a story/spec."
22
- exit 1
22
+ # Portable across bash 3.2 (macOS) and 4+ — no mapfile; feed the loop via heredoc (not a pipe) so
23
+ # the failure count survives the loop body.
24
+ commits="$(git rev-list --no-merges "$RANGE")"
25
+ if [ -z "$commits" ]; then
26
+ echo "PASS [spec-link]: no non-merge commits in ${RANGE}"
27
+ exit 0
23
28
  fi
24
29
 
25
30
  rc=0
26
- while IFS= read -r t; do
27
- [ -z "$t" ] && continue
28
- story="$(printf '%s' "$t" | sed -E 's/-T[0-9]+$//')"
31
+ while IFS= read -r sha; do
32
+ [ -z "$sha" ] && continue
33
+ short="$(git log -1 --format=%h "$sha")"
34
+ subject="$(git log -1 --format=%s "$sha")"
35
+ if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: "; then
36
+ echo "PASS [spec-link]: ${short} '${subject}' — maintenance commit (exempt)"
37
+ continue
38
+ fi
39
+ task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
40
+ if [ -z "$task" ]; then
41
+ echo "FAIL [spec-link]: ${short} '${subject}' has no 'Task:' trailer"
42
+ rc=1
43
+ continue
44
+ fi
45
+ # The trailer must be a real <story>-T<NN> id. Without this guard a malformed trailer
46
+ # (e.g. 'EP-demo-S01' with no -T<NN>) would survive the suffix-strip unchanged and PASS
47
+ # whenever specs/<that>/link.md happens to exist.
48
+ if ! printf '%s' "$task" | grep -qE '.+-T[0-9]+$'; then
49
+ echo "FAIL [spec-link]: ${short} '${subject}' has a malformed Task trailer '${task}' (expected <story>-T<NN>)."
50
+ rc=1
51
+ continue
52
+ fi
53
+ story="$(printf '%s' "$task" | sed -E 's/-T[0-9]+$//')"
29
54
  if [ -f "specs/${story}/link.md" ]; then
30
- echo "PASS [spec-link]: ${t} -> specs/${story}/link.md"
55
+ echo "PASS [spec-link]: ${short} ${task} -> specs/${story}/link.md"
31
56
  else
32
- echo "FAIL [spec-link]: ${t} references specs/${story}/ but link.md is missing."
57
+ echo "FAIL [spec-link]: ${short} ${task} references specs/${story}/ but link.md is missing."
33
58
  rc=1
34
59
  fi
35
60
  done <<EOF
36
- $tasks
61
+ $commits
37
62
  EOF
38
63
  exit "$rc"
@@ -9,7 +9,14 @@
9
9
  # pipeline's default stage regardless of any `stages:` order the foreign root defines — a foreign
10
10
  # `stages:` list can neither break them nor reorder them. Job names are yad-prefixed to avoid
11
11
  # colliding with the host project's own job names.
12
- default:
12
+ #
13
+ # `image:`/`tags:` live on the .sdlc_mr_only anchor below — NOT a top-level `default:`. An included
14
+ # top-level `default:` merges into the HOST root pipeline; if the host already sets a top-level
15
+ # `image:`, GitLab rejects the whole config ("image is defined in top-level and 'default:' entry").
16
+ # Putting them on the anchor scopes them to the gate jobs (which all `extends: .sdlc_mr_only`) and
17
+ # never touches the host root.
18
+
19
+ .sdlc_mr_only:
13
20
  image: node:20
14
21
  # Runner selection. These jobs need a docker/dind executor; on instances whose runners are all
15
22
  # tag-locked (run_untagged: false) an untagged image job never starts. Set the YAD_RUNNER_TAGS
@@ -19,8 +26,6 @@ default:
19
26
  # instead strand it `pending` — if so, set the variable to a real tag. Single value only:
20
27
  # `tags: [$VAR]` is one tag equal to the whole variable, not a comma-split.
21
28
  tags: [$YAD_RUNNER_TAGS]
22
-
23
- .sdlc_mr_only:
24
29
  rules:
25
30
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
26
31
 
@@ -45,6 +50,7 @@ yad-build-test-lint:
45
50
  variables:
46
51
  YAD_TEST_MAX_WORKERS: "2" # cap jest/vitest test workers in CI; ignored by other runners
47
52
  script:
53
+ - npm ci
48
54
  - bash checks/build-test-lint.sh
49
55
 
50
56
  # Pattern gates: commit subject + MR title + MR body all follow the convention (profile: code).
@@ -14,34 +14,69 @@ import { RoleSelectPage } from './pages/RoleSelectPage';
14
14
  import { StakeholderDocPage } from './pages/StakeholderDocPage';
15
15
  import { usePlayback } from './hooks/usePlayback';
16
16
  import { useFlowStore } from './store/useFlowStore';
17
+ import { Icon } from './components/shared/Icon';
18
+
19
+ const railStyle = {
20
+ borderColor: 'var(--color-border-default)',
21
+ background: 'var(--color-bg-primary)',
22
+ };
17
23
 
18
24
  function Dashboard() {
19
25
  usePlayback();
20
26
  const isLogsPanelOpen = useFlowStore((s) => s.isLogsPanelOpen);
27
+ const isLeftPanelOpen = useFlowStore((s) => s.isLeftPanelOpen);
28
+ const isRightPanelOpen = useFlowStore((s) => s.isRightPanelOpen);
29
+ const toggleLeftPanel = useFlowStore((s) => s.toggleLeftPanel);
30
+ const toggleRightPanel = useFlowStore((s) => s.toggleRightPanel);
21
31
 
22
32
  return (
23
33
  <div className="flex flex-1 overflow-hidden">
24
- {/* Left Sidebar */}
25
- <aside
26
- className="w-80 flex-none flex flex-col border-r z-10"
27
- style={{
28
- borderColor: 'var(--color-border-default)',
29
- background: 'var(--color-bg-primary)',
30
- }}
31
- >
32
- {/* Path Selection */}
33
- <div className="p-4 border-b overflow-y-auto" style={{ borderColor: 'var(--color-border-default)' }}>
34
- <PathSelector />
35
- </div>
34
+ {/* Left Sidebar (collapsible — collapse to widen the canvas) */}
35
+ {isLeftPanelOpen ? (
36
+ <aside
37
+ className="w-80 flex-none flex flex-col border-r z-10"
38
+ style={railStyle}
39
+ >
40
+ {/* Collapse header */}
41
+ <div className="flex items-center justify-between px-4 pt-3 pb-1">
42
+ <span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
43
+ Navigator
44
+ </span>
45
+ <button
46
+ onClick={toggleLeftPanel}
47
+ title="Collapse panel"
48
+ aria-label="Collapse navigator panel"
49
+ className="flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
50
+ >
51
+ <Icon name="left_panel_close" size={18} />
52
+ </button>
53
+ </div>
36
54
 
37
- {/* Step Timeline */}
38
- <div className="flex-1 overflow-y-auto p-4">
39
- <StepList />
40
- </div>
55
+ {/* Path Selection */}
56
+ <div className="p-4 border-b overflow-y-auto" style={{ borderColor: 'var(--color-border-default)' }}>
57
+ <PathSelector />
58
+ </div>
41
59
 
42
- {/* Footer */}
43
- <SidebarFooter />
44
- </aside>
60
+ {/* Step Timeline */}
61
+ <div className="flex-1 overflow-y-auto p-4">
62
+ <StepList />
63
+ </div>
64
+
65
+ {/* Footer */}
66
+ <SidebarFooter />
67
+ </aside>
68
+ ) : (
69
+ <div className="w-10 flex-none flex flex-col items-center border-r z-10 pt-3" style={railStyle}>
70
+ <button
71
+ onClick={toggleLeftPanel}
72
+ title="Expand navigator"
73
+ aria-label="Expand navigator panel"
74
+ className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
75
+ >
76
+ <Icon name="left_panel_open" size={18} />
77
+ </button>
78
+ </div>
79
+ )}
45
80
 
46
81
  {/* Main Canvas Area */}
47
82
  <main className="flex-1 flex flex-col overflow-hidden relative flow-grid"
@@ -65,8 +100,21 @@ function Dashboard() {
65
100
  <PlaybackBar />
66
101
  </main>
67
102
 
68
- {/* Right Detail Panel */}
69
- <RightPanel />
103
+ {/* Right Detail Panel (collapsible — collapse to widen the canvas) */}
104
+ {isRightPanelOpen ? (
105
+ <RightPanel />
106
+ ) : (
107
+ <div className="w-10 flex-none flex flex-col items-center border-l z-10 pt-3" style={railStyle}>
108
+ <button
109
+ onClick={toggleRightPanel}
110
+ title="Expand details"
111
+ aria-label="Expand step-details panel"
112
+ className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
113
+ >
114
+ <Icon name="right_panel_open" size={18} />
115
+ </button>
116
+ </div>
117
+ )}
70
118
  </div>
71
119
  );
72
120
  }
@@ -75,6 +75,26 @@ export const FlowCanvas: React.FC = () => {
75
75
 
76
76
  const messages = currentStep?.messages || [];
77
77
 
78
+ // Optional loop-back arc, swept below the canvas to read as "the pipeline repeats".
79
+ // Rendered only when both endpoint nodes exist (e.g. the SDLC-overview's
80
+ // trust-log → product-hub cycle); a no-op for diagrams without them.
81
+ const loopBack = useMemo(() => {
82
+ if (dims.width === 0) return null;
83
+ const a = COMPONENTS.find((c) => c.id === 'trust-log');
84
+ const b = COMPONENTS.find((c) => c.id === 'product-hub');
85
+ if (!a || !b) return null;
86
+ const x1 = (a.position.x / 100) * dims.width;
87
+ const y1 = (a.position.y / 100) * dims.height;
88
+ const x2 = (b.position.x / 100) * dims.width;
89
+ const y2 = (b.position.y / 100) * dims.height;
90
+ const yb = dims.height * 0.985;
91
+ return {
92
+ d: `M ${x1} ${y1} C ${x1} ${yb}, ${x2} ${yb}, ${x2} ${y2}`,
93
+ labelX: (x1 + x2) / 2,
94
+ labelY: dims.height * 0.92,
95
+ };
96
+ }, [dims]);
97
+
78
98
  return (
79
99
  <div ref={containerRef} className="relative h-full w-full overflow-hidden">
80
100
  {/* Canvas Header Overlay */}
@@ -159,6 +179,33 @@ export const FlowCanvas: React.FC = () => {
159
179
  containerHeight={dims.height}
160
180
  />
161
181
  ))}
182
+
183
+ {/* Loop-back: the pipeline repeats per epic */}
184
+ {loopBack && (
185
+ <g>
186
+ <path
187
+ d={loopBack.d}
188
+ fill="none"
189
+ stroke="#ff6490"
190
+ strokeWidth={1.5}
191
+ strokeDasharray="6 5"
192
+ opacity={0.55}
193
+ markerEnd="url(#arrowhead)"
194
+ />
195
+ <text
196
+ x={loopBack.labelX}
197
+ y={loopBack.labelY}
198
+ textAnchor="middle"
199
+ fontSize={12}
200
+ fontWeight={700}
201
+ letterSpacing={0.6}
202
+ fill="#ff8db0"
203
+ opacity={0.85}
204
+ >
205
+ ↺ pipeline repeats per epic
206
+ </text>
207
+ </g>
208
+ )}
162
209
  </svg>
163
210
  )}
164
211
 
@@ -12,6 +12,7 @@ export function RightPanel() {
12
12
  const getCurrentStep = useFlowStore((s) => s.getCurrentStep);
13
13
  const activeStepIndex = useFlowStore((s) => s.activeStepIndex);
14
14
  const selectedPath = useFlowStore((s) => s.selectedPath);
15
+ const toggleRightPanel = useFlowStore((s) => s.toggleRightPanel);
15
16
  const navigate = useNavigate();
16
17
  const step = getCurrentStep();
17
18
 
@@ -29,7 +30,14 @@ export function RightPanel() {
29
30
  <h3 className="text-slate-100 text-sm font-bold font-display uppercase tracking-wider">
30
31
  Step Details
31
32
  </h3>
32
- <Icon name="close" size={20} className="text-slate-500 cursor-pointer hover:text-white" />
33
+ <button
34
+ onClick={toggleRightPanel}
35
+ title="Collapse panel"
36
+ aria-label="Collapse step-details panel"
37
+ className="flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
38
+ >
39
+ <Icon name="right_panel_close" size={18} />
40
+ </button>
33
41
  </div>
34
42
 
35
43
  {!step ? (
@@ -43,15 +43,6 @@ export const StepList = () => {
43
43
  }
44
44
  };
45
45
 
46
- // Reset expanded state when active step changes externally (playback)
47
- const prevActiveRef = { current: activeStepIndex };
48
- if (prevActiveRef.current !== activeStepIndex) {
49
- prevActiveRef.current = activeStepIndex;
50
- if (expandedIndex !== null && expandedIndex !== activeStepIndex) {
51
- // Auto-expand the new active step during playback
52
- }
53
- }
54
-
55
46
  return (
56
47
  <div className="flex flex-col gap-2">
57
48
  <div className="flex items-center justify-between mb-2">
@@ -28,13 +28,16 @@ export function CommandPalette() {
28
28
  return () => window.removeEventListener('keydown', handler);
29
29
  }, [isOpen, toggle]);
30
30
 
31
- // Reset on open
32
- useEffect(() => {
31
+ // Reset on open — adjust state during render (not inside an effect) so it does
32
+ // not trigger an extra cascading render (react-hooks/set-state-in-effect).
33
+ const [wasOpen, setWasOpen] = useState(isOpen);
34
+ if (isOpen !== wasOpen) {
35
+ setWasOpen(isOpen);
33
36
  if (isOpen) {
34
37
  setQuery('');
35
38
  setSelectedIndex(0);
36
39
  }
37
- }, [isOpen]);
40
+ }
38
41
 
39
42
  const results = useMemo(() => {
40
43
  if (!query.trim()) {
@@ -9,13 +9,22 @@ export function useAnimationQueue() {
9
9
  const [animKey, setAnimKey] = useState(0);
10
10
  const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
11
11
 
12
- // When step changes, reset and schedule completion events
12
+ // Reset the pulse state the instant the step (or speed) changes done during
13
+ // render via the "adjust state on change" pattern rather than inside the effect,
14
+ // so it does not trigger an extra cascading render (react-hooks/set-state-in-effect).
15
+ const stepKey = `${activeStepIndex}-${speed}`;
16
+ const [resetKey, setResetKey] = useState(stepKey);
17
+ if (stepKey !== resetKey) {
18
+ setResetKey(stepKey);
19
+ setCompletedTargets(new Set());
20
+ setAnimKey((k) => k + 1);
21
+ }
22
+
23
+ // When step changes, schedule the target-pulse completion events.
13
24
  useEffect(() => {
14
25
  // Clear previous timers
15
26
  timersRef.current.forEach(clearTimeout);
16
27
  timersRef.current = [];
17
- setCompletedTargets(new Set());
18
- setAnimKey((k) => k + 1);
19
28
 
20
29
  // Get messages from the current step
21
30
  const step = useFlowStore.getState().getCurrentStep();
@@ -30,7 +30,7 @@ export function usePlayback() {
30
30
  return () => {
31
31
  if (elapsedRef.current) clearInterval(elapsedRef.current);
32
32
  };
33
- }, [playbackState]);
33
+ }, [playbackState, tickElapsed, resetElapsed]);
34
34
 
35
35
  // Generate logs on step change
36
36
  useEffect(() => {
@@ -56,7 +56,7 @@ export function usePlayback() {
56
56
  });
57
57
  }, msg.delay / speed);
58
58
  }
59
- }, [activeStepIndex, speed]);
59
+ }, [activeStepIndex, speed, addLog, getCurrentSteps]);
60
60
 
61
61
  useEffect(() => {
62
62
  if (playbackState !== "playing") {
@@ -96,5 +96,5 @@ export function usePlayback() {
96
96
  return () => {
97
97
  if (timerRef.current) clearTimeout(timerRef.current);
98
98
  };
99
- }, [playbackState, activeStepIndex, speed, animatingMessages]);
99
+ }, [playbackState, activeStepIndex, speed, animatingMessages, getCurrentSteps, nextStep, setAnimatingMessages]);
100
100
  }
@@ -26,6 +26,8 @@ interface FlowStore {
26
26
  isReferencePanelOpen: boolean;
27
27
  isCommandPaletteOpen: boolean;
28
28
  isLogsPanelOpen: boolean;
29
+ isLeftPanelOpen: boolean;
30
+ isRightPanelOpen: boolean;
29
31
 
30
32
  // Logs
31
33
  logs: LogEntry[];
@@ -57,6 +59,8 @@ interface FlowStore {
57
59
  toggleReferencePanel: () => void;
58
60
  toggleCommandPalette: () => void;
59
61
  toggleLogsPanel: () => void;
62
+ toggleLeftPanel: () => void;
63
+ toggleRightPanel: () => void;
60
64
 
61
65
  // Logs
62
66
  addLog: (entry: Omit<LogEntry, 'id' | 'timestamp'>) => void;
@@ -90,6 +94,8 @@ export const useFlowStore = create<FlowStore>((set, get) => ({
90
94
  isReferencePanelOpen: false,
91
95
  isCommandPaletteOpen: false,
92
96
  isLogsPanelOpen: false,
97
+ isLeftPanelOpen: true,
98
+ isRightPanelOpen: true,
93
99
  logs: [],
94
100
  elapsedTime: 0,
95
101
 
@@ -157,6 +163,8 @@ export const useFlowStore = create<FlowStore>((set, get) => ({
157
163
  toggleReferencePanel: () => set((s) => ({ isReferencePanelOpen: !s.isReferencePanelOpen })),
158
164
  toggleCommandPalette: () => set((s) => ({ isCommandPaletteOpen: !s.isCommandPaletteOpen })),
159
165
  toggleLogsPanel: () => set((s) => ({ isLogsPanelOpen: !s.isLogsPanelOpen })),
166
+ toggleLeftPanel: () => set((s) => ({ isLeftPanelOpen: !s.isLeftPanelOpen })),
167
+ toggleRightPanel: () => set((s) => ({ isRightPanelOpen: !s.isRightPanelOpen })),
160
168
 
161
169
  // Logs
162
170
  addLog: (entry) => {