Skip to content

Commit d5e36be

Browse files
juanmaguitarclaude
andcommitted
Point the contributor at the next setup step
Contributors reach a screen and don't know what to do next; the app knows which step is pending but never says so, and the control may be scrolled out of view (#252). Add a pure resolver, `next-action.cjs`, that folds the setup checklist's own `current` step together with the post-init state into a single id — the one block to point at, or null. The renderer tags the matching block with `data-next-action` and a `.next-action-cue` class (a ring plus a "Start here" pill), and `useNextActionCue` scrolls it into view when the id changes. The mark is pure React so it survives re-render; the hook does only the scroll. The cue is gated on the row being active — every SiteRow stays mounted — and respects prefers-reduced-motion: the pulse drops to a static ring and the scroll to an instant jump. This PR wires the setup checklist only. The post-init panels get their anchors in a follow-up; the resolver already returns their ids, which no DOM element carries yet, so the hook harmlessly no-ops for them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b5e5ed6 commit d5e36be

4 files changed

Lines changed: 286 additions & 1 deletion

File tree

‎src/renderer/index.html‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@
4848
.patch-columns > .patch-destinations { flex: none; max-width: none; overflow-y: visible; }
4949
}
5050
.components-dropdown-menu__toggle { display: flex; flex-direction: row-reverse; gap: 4px; }
51+
/*
52+
The cue that points a contributor at the next thing to do (#252). The
53+
render puts `.next-action-cue` on exactly one block — whichever the
54+
resolver named — and the cue hook scrolls it into view. A soft amber glow
55+
marks it: subtle, no motion, and it sits over the block's own background
56+
rather than replacing it, so a coloured banner (the red "update
57+
incomplete", say) keeps its meaning. The block's own status text names the
58+
step, so the glow carries no label. No animation, so nothing here needs a
59+
reduced-motion guard; the only movement is the scroll, gated in the hook.
60+
61+
`border-radius` here only rounds the glow around the wrapped action
62+
buttons, which set none of their own. The blocks set their radius inline,
63+
which the glow follows and this does not override.
64+
*/
65+
.next-action-cue {
66+
border-radius: 10px;
67+
box-shadow: 0 0 0 3px rgba(240, 184, 73, 0.45), 0 0 14px 3px rgba(240, 184, 73, 0.30);
68+
}
5169
</style>
5270
<link rel="stylesheet" href="index.css" />
5371
<script defer src="index.js"></script>

‎src/renderer/index.jsx‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import '@wordpress/components/build-style/style.css';
2121
import { Terminal } from 'xterm';
2222
import 'xterm/css/xterm.css';
2323
import { computeSetupStepState } from './setup-steps.cjs';
24+
import { deriveNextAction } from './next-action.cjs';
2425
import { shouldShowTerminalHints, computeTerminalBusy } from './terminal-hints.cjs';
2526
import { planDevServerStart, formatElapsed } from './dev-server-command.cjs';
2627
import { appendBounded, countLines } from './debug-log.cjs';
@@ -201,6 +202,34 @@ function useContributorProvenance() {
201202
return { handle, event, rememberHandle, rememberEvent };
202203
}
203204

205+
// Brings the block the contributor should act on next into view when it changes,
206+
// so their one hint is never left below the fold (#252). The mark itself is
207+
// drawn by React — the `.next-action-cue` class the render binds to this same id
208+
// — because a className survives re-render where an imperative one would be
209+
// reconciled away; this handles only the movement, which no className can do.
210+
//
211+
// Gated on `isActive` because every SiteRow stays mounted at once: without it a
212+
// background site could yank the viewport the moment its own state changed. It
213+
// fires on a *change* of the id (or on becoming active), not on every render, so
214+
// a contributor reading one block is not dragged off it by an unrelated update.
215+
function useNextActionCue(nextActionId, isActive, containerRef) {
216+
useEffect(() => {
217+
if (!isActive || !nextActionId) return;
218+
const root = containerRef.current;
219+
if (!root) return;
220+
const el = root.querySelector(`[data-next-action="${nextActionId}"]`);
221+
if (!el) return;
222+
// `center` brings the block clearly into view rather than just nudging it to
223+
// the nearest edge — the cue only fires when the target *changes*, so this
224+
// moves the viewport to whatever is newly worth looking at (a step that just
225+
// became current, an operation that just started) without fighting a
226+
// contributor mid-read. Reduced motion drops the smooth glide to an instant
227+
// jump.
228+
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
229+
el.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
230+
}, [nextActionId, isActive, containerRef]);
231+
}
232+
204233
// The two halves are one piece of state because one change moves both: adopting
205234
// the directory the app really created has to retire the guessed row and carry
206235
// its metadata across, and two setters cannot do that without a render in
@@ -1728,6 +1757,9 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
17281757

17291758
// terminal refs/state (after run helpers so dependencies are available)
17301759
const terminalContainerRef = useRef(null);
1760+
// The scroll root for the next-action cue (#252): the whole detail section, so
1761+
// the cue can find whichever block is the next step wherever it sits.
1762+
const nextActionSectionRef = useRef(null);
17311763
const terminalRef = useRef(null);
17321764
const terminalStickRef = useRef(true);
17331765
const terminalInputHandlerRef = useRef(() => {});
@@ -3470,8 +3502,27 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
34703502
return { ...step, status };
34713503
});
34723504

3505+
// The one block to point the contributor at next (#252). The checklist has
3506+
// already worked out its own current step; the resolver folds that together
3507+
// with the post-init state into a single id, which the render tags onto the
3508+
// matching block (`data-next-action` + the `.next-action-cue` class) and the
3509+
// cue hook scrolls into view.
3510+
const currentSetupStep = stepItems.find((s) => s.status === 'current')?.key || null;
3511+
const nextAction = deriveNextAction({
3512+
skipInit,
3513+
currentSetupStep,
3514+
updateIncomplete,
3515+
isUpdating,
3516+
stale: age.stale,
3517+
running,
3518+
hasChanges: Boolean(worktreeDirty && worktreeDirty.dirty),
3519+
ticketLinked: Boolean(tracTicket)
3520+
});
3521+
const nextActionId = nextAction ? nextAction.id : null;
3522+
useNextActionCue(nextActionId, isActive, nextActionSectionRef);
3523+
34733524
return (
3474-
<section style={{ display: 'flex', flexDirection: 'column', gap: 24, paddingBottom: 48 }}>
3525+
<section ref={nextActionSectionRef} style={{ display: 'flex', flexDirection: 'column', gap: 24, paddingBottom: 48 }}>
34753526
<Flex align="flex-start" justify="space-between" style={{ gap: 16, flexWrap: 'wrap' }}>
34763527
<div style={{ flex: '1 1 440px', minWidth: 0 }}>
34773528
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
@@ -3687,9 +3738,12 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
36873738
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
36883739
{stepItems.map((step) => {
36893740
const visuals = checklistVisuals[step.status] || checklistVisuals.locked;
3741+
const cueId = `setup-${step.key}`;
36903742
return (
36913743
<div
36923744
key={step.key}
3745+
data-next-action={cueId}
3746+
className={nextActionId === cueId ? 'next-action-cue' : undefined}
36933747
style={{
36943748
border: `1px solid ${visuals.border}`,
36953749
background: visuals.background,

‎src/renderer/next-action.cjs‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use strict';
2+
3+
/**
4+
* Decides the one thing the contributor should do next, anywhere in a site's
5+
* detail view.
6+
*
7+
* The view is a stack of panels — a setup checklist, then (once the wizard is
8+
* skipped) banners for an incomplete or stale update, a dev-server bar, a
9+
* ticket panel, a patch panel. Each panel already knows whether it has
10+
* something to offer, but nothing looked across them to say which one is *the*
11+
* next step. A contributor lands on the screen and the relevant control may be
12+
* scrolled out of view, or sit among several with nothing to say "start here"
13+
* (#252).
14+
*
15+
* This is that decision, and only the decision. It returns the `id` of the
16+
* block to point at — a stable string the render tags onto that block with
17+
* `data-next-action` — or `null` when nothing is pending. Pure and
18+
* dependency-free so it can be unit tested without a DOM: the renderer imports
19+
* it, `node --test` requires it directly, exactly like `setup-steps.cjs`.
20+
*
21+
* The order below is a priority ladder, most urgent first. It is deliberate,
22+
* not incidental: an incomplete update (the code moved but the built assets did
23+
* not, so the site may not run) outranks everything, because ignoring it wastes
24+
* the rest of the session. A stale tree outranks routine work because a patch
25+
* made against old code may not apply on Trac. Only then do the routine steps
26+
* come — get the server up, review pending changes, link a ticket — and a site
27+
* that is running, clean and linked has no next action at all.
28+
*
29+
* @param {Object} state
30+
* @param {boolean} state.skipInit Init wizard skipped; post-init view shown.
31+
* @param {?string} state.currentSetupStep The checklist's current step key, or null.
32+
* @param {boolean} state.updateIncomplete Code updated but built assets are stale.
33+
* @param {boolean} state.isUpdating A trunk update is running now.
34+
* @param {boolean} state.stale The trunk snapshot is old (#94).
35+
* @param {boolean} state.running The dev server is up.
36+
* @param {boolean} state.hasChanges The working tree has uncommitted edits.
37+
* @param {boolean} state.ticketLinked A Trac ticket is linked.
38+
* @return {?{id: string, reason: string}} The block to point at, or null.
39+
*/
40+
function deriveNextAction(state = {}) {
41+
const skipInit = Boolean(state.skipInit);
42+
43+
// Before the wizard is skipped the checklist is the whole story, and it has
44+
// already worked out its own next step (exactly one row is `current`). Reuse
45+
// that rather than re-deriving it from the raw flags — there is one source of
46+
// truth for the checklist, and it is the checklist.
47+
if (!skipInit) {
48+
if (typeof state.currentSetupStep === 'string' && state.currentSetupStep) {
49+
return {
50+
id: `setup-${state.currentSetupStep}`,
51+
reason: 'The next step in the setup checklist.'
52+
};
53+
}
54+
return null;
55+
}
56+
57+
if (Boolean(state.updateIncomplete) && !Boolean(state.isUpdating)) {
58+
return {
59+
id: 'retry-install-build',
60+
reason: 'The update left the build stale; install and build to recover.'
61+
};
62+
}
63+
64+
// An update in flight is not an action, but it is the thing happening — point
65+
// at its tracker so a contributor who looked away can find where the work is.
66+
if (Boolean(state.isUpdating)) {
67+
return { id: 'updating', reason: 'The trunk update in progress.' };
68+
}
69+
70+
if (Boolean(state.stale)) {
71+
return {
72+
id: 'update-trunk',
73+
reason: 'The trunk snapshot is old; update before making a patch.'
74+
};
75+
}
76+
77+
if (!Boolean(state.running)) {
78+
return { id: 'start-dev', reason: 'Start the dev server to work on the site.' };
79+
}
80+
81+
if (Boolean(state.hasChanges)) {
82+
return {
83+
id: 'review-changes',
84+
reason: 'You have uncommitted changes ready to review and submit.'
85+
};
86+
}
87+
88+
if (!Boolean(state.ticketLinked)) {
89+
return { id: 'link-ticket', reason: 'Link a Trac ticket to give your work a home.' };
90+
}
91+
92+
return null;
93+
}
94+
95+
module.exports = { deriveNextAction };

‎test/next-action.test.cjs‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert');
5+
6+
const { deriveNextAction } = require('../src/renderer/next-action.cjs');
7+
8+
test('during setup, the next action is the checklist\'s current step', () => {
9+
const next = deriveNextAction({ skipInit: false, currentSetupStep: 'install' });
10+
11+
assert.deepStrictEqual(next && next.id, 'setup-install');
12+
});
13+
14+
test('setup defers entirely to the checklist, ignoring post-init flags', () => {
15+
// The post-init flags below would each point somewhere if the wizard were
16+
// skipped; while the checklist shows, none of them may override it.
17+
const next = deriveNextAction({
18+
skipInit: false,
19+
currentSetupStep: 'build',
20+
updateIncomplete: true,
21+
stale: true,
22+
running: false
23+
});
24+
25+
assert.strictEqual(next.id, 'setup-build');
26+
});
27+
28+
test('during setup with no current step, nothing is pointed at', () => {
29+
const next = deriveNextAction({ skipInit: false, currentSetupStep: null });
30+
31+
assert.strictEqual(next, null);
32+
});
33+
34+
test('an incomplete update outranks everything post-init', () => {
35+
const next = deriveNextAction({
36+
skipInit: true,
37+
updateIncomplete: true,
38+
stale: true,
39+
running: false,
40+
hasChanges: true,
41+
ticketLinked: false
42+
});
43+
44+
assert.strictEqual(next.id, 'retry-install-build');
45+
});
46+
47+
test('an update in flight suppresses the retry banner and points at its tracker', () => {
48+
// updateIncomplete is set during the window an update owns the tree; while the
49+
// update is actually running there is nothing to retry, only progress to find.
50+
const next = deriveNextAction({
51+
skipInit: true,
52+
updateIncomplete: true,
53+
isUpdating: true
54+
});
55+
56+
assert.strictEqual(next.id, 'updating');
57+
});
58+
59+
test('a stale tree outranks the routine steps', () => {
60+
const next = deriveNextAction({
61+
skipInit: true,
62+
stale: true,
63+
running: false,
64+
hasChanges: true,
65+
ticketLinked: false
66+
});
67+
68+
assert.strictEqual(next.id, 'update-trunk');
69+
});
70+
71+
test('with nothing urgent, the first routine step is starting the dev server', () => {
72+
const next = deriveNextAction({
73+
skipInit: true,
74+
running: false,
75+
hasChanges: true,
76+
ticketLinked: false
77+
});
78+
79+
assert.strictEqual(next.id, 'start-dev');
80+
});
81+
82+
test('a running server with pending changes points at reviewing them', () => {
83+
const next = deriveNextAction({
84+
skipInit: true,
85+
running: true,
86+
hasChanges: true,
87+
ticketLinked: false
88+
});
89+
90+
assert.strictEqual(next.id, 'review-changes');
91+
});
92+
93+
test('a running, clean site with no ticket points at linking one', () => {
94+
const next = deriveNextAction({
95+
skipInit: true,
96+
running: true,
97+
hasChanges: false,
98+
ticketLinked: false
99+
});
100+
101+
assert.strictEqual(next.id, 'link-ticket');
102+
});
103+
104+
test('a running, clean, linked site has no next action', () => {
105+
const next = deriveNextAction({
106+
skipInit: true,
107+
running: true,
108+
hasChanges: false,
109+
ticketLinked: true
110+
});
111+
112+
assert.strictEqual(next, null);
113+
});
114+
115+
test('missing state is treated as nothing pending, not a crash', () => {
116+
assert.strictEqual(deriveNextAction(), null);
117+
assert.strictEqual(deriveNextAction({}), null);
118+
});

0 commit comments

Comments
 (0)