Leandro von Werra commited on
Commit
c19e556
·
unverified ·
2 Parent(s): 18e535a63bc919

Merge pull request #11 from huggingface/bugfixes

Browse files

Four fixes: agent subpages, the pending selection, overlapping marks, the star

Files changed (5) hide show
  1. client/app.css +18 -1
  2. client/src/doc.js +54 -9
  3. server/api.js +67 -17
  4. test/browser.js +103 -0
  5. test/smoke.js +53 -0
client/app.css CHANGED
@@ -387,6 +387,15 @@ textarea { resize: vertical; }
387
  .tiptap img.ProseMirror-selectednode { outline: 3px solid var(--accent-soft); outline-offset: 2px; }
388
  .tiptap .comment-hl { background: var(--hl); border-bottom: 1.5px solid var(--hl-edge); cursor: pointer; }
389
  .tiptap .comment-hl.active { background: var(--hl-active); }
 
 
 
 
 
 
 
 
 
390
  .tiptap .suggestion-hl { background: transparent; position: relative; }
391
  .tiptap .suggestion-hl::before { content: ''; position: absolute; left: -16px; top: 2px; bottom: 2px; width: 2.5px; background: var(--sugg-bar); }
392
  .tiptap p.is-editor-empty:first-child::before { color: var(--text-3); content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
@@ -817,7 +826,15 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
817
  #page-tree .group-row.lvl-3 .group-label,
818
  #page-tree .group-row.lvl-4 .group-label { color: var(--text-2); font-size: 12px; }
819
  #page-tree .group-row:hover { --sidebar-row-bg: var(--hl-weak); background: var(--sidebar-row-bg); }
820
- #page-tree .home-star { width: 12px; height: 12px; flex: 0 0 12px; color: var(--text-3); }
 
 
 
 
 
 
 
 
821
  #page-tree .sidebar-rename {
822
  flex: 1; min-width: 0; margin: 1px 3px; padding: 3px 5px; border: 1px solid var(--accent); border-radius: 5px;
823
  background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
 
387
  .tiptap img.ProseMirror-selectednode { outline: 3px solid var(--accent-soft); outline-offset: 2px; }
388
  .tiptap .comment-hl { background: var(--hl); border-bottom: 1.5px solid var(--hl-edge); cursor: pointer; }
389
  .tiptap .comment-hl.active { background: var(--hl-active); }
390
+ /* What the open composer is about. The browser drops its own selection
391
+ highlight the moment focus moves to the box, so this stands in for it —
392
+ dashed, to read as "not committed yet" next to real comment highlights. */
393
+ .tiptap .pending-hl { background: var(--hl-weak); border-bottom: 1.5px dashed var(--hl-edge); }
394
+ /* A comment and a suggestion over the same words: the comment keeps its
395
+ underline but drops the fill, so the suggestion's inserted and deleted spans
396
+ are not two tints deep and stay legible. */
397
+ .tiptap .comment-hl.with-sugg { background: transparent; border-bottom-style: dotted; }
398
+ .tiptap .comment-hl.with-sugg.active { background: transparent; border-bottom-width: 2px; }
399
  .tiptap .suggestion-hl { background: transparent; position: relative; }
400
  .tiptap .suggestion-hl::before { content: ''; position: absolute; left: -16px; top: 2px; bottom: 2px; width: 2.5px; background: var(--sugg-bar); }
401
  .tiptap p.is-editor-empty:first-child::before { color: var(--text-3); content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
 
826
  #page-tree .group-row.lvl-3 .group-label,
827
  #page-tree .group-row.lvl-4 .group-label { color: var(--text-2); font-size: 12px; }
828
  #page-tree .group-row:hover { --sidebar-row-bg: var(--hl-weak); background: var(--sidebar-row-bg); }
829
+ #page-tree .home-star {
830
+ width: 12px; height: 12px; flex: 0 0 12px; color: var(--text-3);
831
+ /* out of the flow: the row's caret gutter is 16px, and the star sits in it so
832
+ home's title lines up with every other page's */
833
+ position: absolute; left: 2px; top: 50%; transform: translateY(-50%); pointer-events: none;
834
+ }
835
+ /* home with children: the caret owns the gutter, so the star trails the title
836
+ instead of landing on top of it — still without moving the title */
837
+ #page-tree .home-star.trailing { position: static; transform: none; margin: 0 4px 0 2px; }
838
  #page-tree .sidebar-rename {
839
  flex: 1; min-width: 0; margin: 1px 3px; padding: 3px 5px; border: 1px solid var(--accent); border-radius: 5px;
840
  background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
client/src/doc.js CHANGED
@@ -1720,12 +1720,18 @@ async function loadStructure() {
1720
  if (!known.has(node.slug)) return
1721
  beginPageRename(a, node.slug, pageTitle)
1722
  })
 
1723
  if (node.slug === 'home') {
 
 
 
 
 
1724
  const star = icon('star')
1725
  star.classList.add('home-star')
 
1726
  row.appendChild(star)
1727
  }
1728
- row.appendChild(a)
1729
  if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug))
1730
  }
1731
  li.appendChild(row)
@@ -2024,11 +2030,42 @@ function buildDecorations(pmState) {
2024
  if (!ystate?.binding) return DecorationSet.empty
2025
  const decos = []
2026
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2027
  threads.forEach((ythread, id) => {
2028
  if (ythread.get('resolved') || ythread.get('suggestionId')) return
2029
  const range = anchorsToRange(pmState, ystate, ythread.get('anchorStart'), ythread.get('anchorEnd'))
2030
  if (!range || range.from >= range.to) return
2031
- const cls = 'comment-hl' + (state.activeItem === id ? ' active' : '')
 
 
 
 
 
 
2032
  decos.push(Decoration.inline(range.from, range.to, { class: cls, 'data-thread': id }))
2033
  })
2034
 
@@ -2588,6 +2625,13 @@ function poke(editor) {
2588
  editor.view.dispatch(editor.view.state.tr.setMeta('collab-refresh', true))
2589
  }
2590
 
 
 
 
 
 
 
 
2591
  function setActive(id) {
2592
  state.activeItem = id
2593
  // revealing a specific item un-hides the comments panel
@@ -2921,7 +2965,7 @@ function openForSelection(editor) {
2921
  function closeAutoComposer() {
2922
  if (!state.autoComposer || state.composerDirty) return
2923
  const isSuggest = state.autoComposer === 'suggest'
2924
- document.getElementById(isSuggest ? 'suggest-composer' : 'composer').classList.add('hidden')
2925
  if (!isSuggest) document.getElementById('composer-text').value = ''
2926
  state.autoComposer = null
2927
  state.suggestRange = null
@@ -2959,7 +3003,7 @@ function openComposer(editor, opts = {}) {
2959
  excerpt: editor.state.doc.textBetween(from, to, ' ', leafText).slice(0, 200),
2960
  }
2961
  document.getElementById('composer-quote').textContent = state.composerAnchors.excerpt
2962
- document.getElementById('suggest-composer').classList.add('hidden')
2963
  document.getElementById('composer').classList.remove('hidden')
2964
  document.getElementById('selection-menu').classList.add('hidden')
2965
  const text = document.getElementById('composer-text')
@@ -2983,6 +3027,7 @@ function openComposer(editor, opts = {}) {
2983
  })
2984
  }
2985
  state.autoComposer = opts.auto ? 'comment' : null
 
2986
  setMarginHidden(false, false) // the composer lives in the comments panel
2987
  renderMargin(editor)
2988
  // an auto-opened box must not steal the keyboard: selecting text and typing
@@ -3082,7 +3127,7 @@ function wireComposer() {
3082
  state.composerDirty = true
3083
  })
3084
  document.getElementById('composer-cancel').addEventListener('click', () => {
3085
- document.getElementById('composer').classList.add('hidden')
3086
  text.value = ''
3087
  state.composerDirty = false
3088
  state.autoComposer = null
@@ -3106,7 +3151,7 @@ function wireComposer() {
3106
  text.value = ''
3107
  state.composerDirty = false
3108
  state.autoComposer = null
3109
- document.getElementById('composer').classList.add('hidden')
3110
  setActive(id)
3111
  })
3112
  wireMentionMenu(text, document.getElementById('composer-mentions'))
@@ -3154,7 +3199,7 @@ async function openSuggestComposer(editor, opts = {}) {
3154
  start: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockFrom)),
3155
  end: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockTo + 1)),
3156
  }
3157
- document.getElementById('composer').classList.add('hidden')
3158
  document.getElementById('selection-menu').classList.add('hidden')
3159
  const composer = document.getElementById('suggest-composer')
3160
  composer.classList.remove('hidden')
@@ -3183,7 +3228,7 @@ function wireSuggestComposer() {
3183
  })
3184
  }
3185
  document.getElementById('suggest-cancel').addEventListener('click', () => {
3186
- document.getElementById('suggest-composer').classList.add('hidden')
3187
  state.composerDirty = false
3188
  state.autoComposer = null
3189
  state.suggestRange = null
@@ -3202,7 +3247,7 @@ function wireSuggestComposer() {
3202
  },
3203
  })
3204
  if (res.error) return uiAlert(res.error)
3205
- document.getElementById('suggest-composer').classList.add('hidden')
3206
  document.getElementById('suggest-text').value = ''
3207
  document.getElementById('suggest-rationale').value = ''
3208
  state.composerDirty = false
 
1720
  if (!known.has(node.slug)) return
1721
  beginPageRename(a, node.slug, pageTitle)
1722
  })
1723
+ row.appendChild(a)
1724
  if (node.slug === 'home') {
1725
+ // The star marks the project's front page. It used to sit before the
1726
+ // label, which pushed home's title right of every other top-level
1727
+ // page; it now hangs in the row's own left gutter so all the titles
1728
+ // start at the same x — except when home has children, because then
1729
+ // the caret already owns that gutter, so the star follows the title.
1730
  const star = icon('star')
1731
  star.classList.add('home-star')
1732
+ if (hasChildren) star.classList.add('trailing')
1733
  row.appendChild(star)
1734
  }
 
1735
  if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug))
1736
  }
1737
  li.appendChild(row)
 
2030
  if (!ystate?.binding) return DecorationSet.empty
2031
  const decos = []
2032
 
2033
+ // Suggestion ranges come first because the comment highlight has to know
2034
+ // whether it is sharing text with one: two filled highlights over the same
2035
+ // words cancel each other out (see below).
2036
+ const suggestionRanges = []
2037
+ for (const chain of suggestionChains()) {
2038
+ if (chain.display.status !== 'open' && chain.display.status !== 'superseded') continue
2039
+ const range = suggestionRange(pmState, chain.display)
2040
+ if (range) suggestionRanges.push(range)
2041
+ }
2042
+ const overlapsSuggestion = range => suggestionRanges.some(r => r.from < range.to && range.from < r.to)
2043
+
2044
+ // What you are about to comment on. Focusing the composer moves focus out of
2045
+ // the editor, which drops the browser's own selection highlight — so without
2046
+ // this the text you selected is no longer marked anywhere while you type
2047
+ // about it.
2048
+ for (const [anchors, boxId] of [
2049
+ [state.composerAnchors, 'composer'],
2050
+ [state.suggestAnchors, 'suggest-composer'],
2051
+ ]) {
2052
+ if (!anchors || document.getElementById(boxId)?.classList.contains('hidden') !== false) continue
2053
+ const range = anchorsToRange(pmState, ystate, anchors.start, anchors.end)
2054
+ if (!range || range.from >= range.to) continue
2055
+ decos.push(Decoration.inline(range.from, range.to, { class: 'pending-hl' }))
2056
+ }
2057
+
2058
  threads.forEach((ythread, id) => {
2059
  if (ythread.get('resolved') || ythread.get('suggestionId')) return
2060
  const range = anchorsToRange(pmState, ystate, ythread.get('anchorStart'), ythread.get('anchorEnd'))
2061
  if (!range || range.from >= range.to) return
2062
+ // A comment and a suggestion on the same words: the comment gives up its
2063
+ // fill and keeps only its underline, so the suggestion's own inserted and
2064
+ // deleted spans stay readable. Two tints stacked made the change invisible.
2065
+ const cls =
2066
+ 'comment-hl' +
2067
+ (state.activeItem === id ? ' active' : '') +
2068
+ (overlapsSuggestion(range) ? ' with-sugg' : '')
2069
  decos.push(Decoration.inline(range.from, range.to, { class: cls, 'data-thread': id }))
2070
  })
2071
 
 
2625
  editor.view.dispatch(editor.view.state.tr.setMeta('collab-refresh', true))
2626
  }
2627
 
2628
+ // Hide a composer box AND drop the highlight that marked what it was about.
2629
+ function hideComposerBox(id) {
2630
+ document.getElementById(id)?.classList.add('hidden')
2631
+ const editor = window.__editor
2632
+ if (editor && !editor.isDestroyed) poke(editor)
2633
+ }
2634
+
2635
  function setActive(id) {
2636
  state.activeItem = id
2637
  // revealing a specific item un-hides the comments panel
 
2965
  function closeAutoComposer() {
2966
  if (!state.autoComposer || state.composerDirty) return
2967
  const isSuggest = state.autoComposer === 'suggest'
2968
+ hideComposerBox(isSuggest ? 'suggest-composer' : 'composer')
2969
  if (!isSuggest) document.getElementById('composer-text').value = ''
2970
  state.autoComposer = null
2971
  state.suggestRange = null
 
3003
  excerpt: editor.state.doc.textBetween(from, to, ' ', leafText).slice(0, 200),
3004
  }
3005
  document.getElementById('composer-quote').textContent = state.composerAnchors.excerpt
3006
+ hideComposerBox('suggest-composer')
3007
  document.getElementById('composer').classList.remove('hidden')
3008
  document.getElementById('selection-menu').classList.add('hidden')
3009
  const text = document.getElementById('composer-text')
 
3027
  })
3028
  }
3029
  state.autoComposer = opts.auto ? 'comment' : null
3030
+ poke(editor) // mark the pending range now that composerAnchors is set
3031
  setMarginHidden(false, false) // the composer lives in the comments panel
3032
  renderMargin(editor)
3033
  // an auto-opened box must not steal the keyboard: selecting text and typing
 
3127
  state.composerDirty = true
3128
  })
3129
  document.getElementById('composer-cancel').addEventListener('click', () => {
3130
+ hideComposerBox('composer')
3131
  text.value = ''
3132
  state.composerDirty = false
3133
  state.autoComposer = null
 
3151
  text.value = ''
3152
  state.composerDirty = false
3153
  state.autoComposer = null
3154
+ hideComposerBox('composer')
3155
  setActive(id)
3156
  })
3157
  wireMentionMenu(text, document.getElementById('composer-mentions'))
 
3199
  start: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockFrom)),
3200
  end: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockTo + 1)),
3201
  }
3202
+ hideComposerBox('composer')
3203
  document.getElementById('selection-menu').classList.add('hidden')
3204
  const composer = document.getElementById('suggest-composer')
3205
  composer.classList.remove('hidden')
 
3228
  })
3229
  }
3230
  document.getElementById('suggest-cancel').addEventListener('click', () => {
3231
+ hideComposerBox('suggest-composer')
3232
  state.composerDirty = false
3233
  state.autoComposer = null
3234
  state.suggestRange = null
 
3247
  },
3248
  })
3249
  if (res.error) return uiAlert(res.error)
3250
+ hideComposerBox('suggest-composer')
3251
  document.getElementById('suggest-text').value = ''
3252
  document.getElementById('suggest-rationale').value = ''
3253
  state.composerDirty = false
server/api.js CHANGED
@@ -183,13 +183,20 @@ export function apiRouter() {
183
  // a page parent is its slug, a group parent is its path array. Checked here
184
  // so a proposal for a parent that does not exist is refused now, rather than
185
  // landing in the wrong place when someone accepts it.
186
- const { parent = null, index = null } = req.body || {}
187
- if (parent != null) {
 
 
188
  await collab.ensureStructurePage(req.params.id)
189
  const structure = getProjectStructure(hocuspocus, req.params.id)
190
- if (!findStructureNode(structure?.tree || [], parent)) {
191
- return res.status(400).json({ error: `unknown parent ${JSON.stringify(parent)} — a page parent is its slug, a group parent is its path array (GET /api/docs/:id/structure)` })
 
 
 
 
192
  }
 
193
  }
194
  if (index != null && !Number.isInteger(index)) return res.status(400).json({ error: 'index must be an integer' })
195
  const result = await collab.createPageProposal(req.params.id, {
@@ -203,7 +210,7 @@ export function apiRouter() {
203
  })
204
  if (result.error) return res.status(400).json({ error: result.error })
205
  if (identity.authorType === 'agent') collab.completeMention(mention_id, {})
206
- res.json({ ok: true, page_suggestion_id: result.id, slug: result.slug })
207
  })
208
 
209
  router.get('/docs/:id/page-suggestions/:pid', requireUser, checkDocId, requireDocAccess, async (req, res) => {
@@ -218,6 +225,8 @@ export function apiRouter() {
218
  author: p.author,
219
  author_type: p.authorType,
220
  status: p.status,
 
 
221
  })
222
  })
223
 
@@ -516,16 +525,56 @@ function checkDocId(req, res, next) {
516
  next()
517
  }
518
 
519
- // Does a {parent} ref name a real node in the tree? A page is its slug, a group
520
- // is its path array (['design', '"Ideas"']) — see pages.js.
521
- function findStructureNode(nodes, ref, path = []) {
522
- for (const node of nodes) {
523
- const here = [...path, node.group != null ? `"${node.group}"` : node.slug]
524
- if (typeof ref === 'string' ? node.slug === ref : Array.isArray(ref) && ref.length === here.length && ref.every((e, i) => e === here[i])) return node
525
- const hit = findStructureNode(node.children || [], ref, here)
526
- if (hit) return hit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  }
528
- return null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  }
530
 
531
  // Markdown bodies arrive under a field name the caller had to guess right.
@@ -643,9 +692,10 @@ Work loop — repeat until the user tells you to stop:
643
  To propose a NEW page, POST a single new-page suggestion with its title and full content:
644
  curl -s "\${AUTH[@]}" -X POST "${HOST}/api/docs/<doc_id>/page-suggestions" -H 'content-type: application/json' \\
645
  -d '{"title": "Page Title", "content_markdown": "# Page Title\\n\\n...", "rationale": "why", "as_agent": "${handle}"}'
646
- Say WHERE it belongs with "parent" and "index" — the same refs /structure/move takes: a
647
- page parent is its slug, a group parent is its path array, and no parent means top level.
648
- Index is the position among that parent's children (omit for last):
 
649
  -d '{"title": "Setup", "content_markdown": "# Setup\\n\\n...", "parent": ["\\"Getting started\\""], "index": 0, "as_agent": "${handle}"}'
650
  -d '{"title": "Notes on X", "content_markdown": "...", "parent": "research", "as_agent": "${handle}"}'
651
  An unknown parent is refused, so read GET .../structure first. It appears in the sidebar
 
183
  // a page parent is its slug, a group parent is its path array. Checked here
184
  // so a proposal for a parent that does not exist is refused now, rather than
185
  // landing in the wrong place when someone accepts it.
186
+ const rawParent = req.body?.parent ?? req.body?.parent_slug ?? req.body?.parent_page ?? req.body?.under ?? null
187
+ const index = req.body?.index ?? null
188
+ let parent = null
189
+ if (rawParent != null) {
190
  await collab.ensureStructurePage(req.params.id)
191
  const structure = getProjectStructure(hocuspocus, req.params.id)
192
+ const resolved = resolveParentRef(structure, rawParent, structure?.titles)
193
+ if (!resolved) {
194
+ return res.status(400).json({
195
+ error: `unknown parent ${JSON.stringify(rawParent)} — pass a page's slug or title, or a group's label (GET /api/docs/:id/structure lists them)`,
196
+ pages: Object.keys(structure?.titles || {}),
197
+ })
198
  }
199
+ parent = resolved.ref
200
  }
201
  if (index != null && !Number.isInteger(index)) return res.status(400).json({ error: 'index must be an integer' })
202
  const result = await collab.createPageProposal(req.params.id, {
 
210
  })
211
  if (result.error) return res.status(400).json({ error: result.error })
212
  if (identity.authorType === 'agent') collab.completeMention(mention_id, {})
213
+ res.json({ ok: true, page_suggestion_id: result.id, slug: result.slug, parent, index })
214
  })
215
 
216
  router.get('/docs/:id/page-suggestions/:pid', requireUser, checkDocId, requireDocAccess, async (req, res) => {
 
225
  author: p.author,
226
  author_type: p.authorType,
227
  status: p.status,
228
+ parent: p.parent ?? null,
229
+ index: Number.isInteger(p.index) ? p.index : null,
230
  })
231
  })
232
 
 
525
  next()
526
  }
527
 
528
+ // Resolve a caller's idea of a parent into the ref the yaml editor takes: a page
529
+ // is its slug, a group is its path array (['design', '"Ideas"']) — see pages.js.
530
+ //
531
+ // An agent works from whatever it can see, and the first thing it sees is a
532
+ // TITLE. Sending "Notes" for the page `notes`, or `Getting started` for the
533
+ // group `"Getting started"`, is the obvious guess and used to be a 400 — which
534
+ // is how a subpage ended up at the top level instead. So the natural spellings
535
+ // resolve, and only a genuinely unknown parent is refused.
536
+ //
537
+ // Returns { ref } for the canonical ref, or null when nothing matches.
538
+ export function resolveParentRef(structure, ref, titles = {}) {
539
+ if (ref == null) return { ref: null }
540
+ const nodes = structure?.tree || []
541
+ const norm = v => String(v).trim().toLowerCase()
542
+
543
+ // exact refs first, so a precise caller is never second-guessed
544
+ const exact = (list, path = []) => {
545
+ for (const node of list) {
546
+ const here = [...path, node.group != null ? `"${node.group}"` : node.slug]
547
+ const hit =
548
+ typeof ref === 'string'
549
+ ? node.group == null && node.slug === ref
550
+ : Array.isArray(ref) && ref.length === here.length && ref.every((e, i) => e === here[i])
551
+ if (hit) return { ref: node.group == null ? node.slug : here }
552
+ const deeper = exact(node.children || [], here)
553
+ if (deeper) return deeper
554
+ }
555
+ return null
556
  }
557
+ const precise = exact(nodes)
558
+ if (precise) return precise
559
+
560
+ // then the forgiving pass: a page title, or a group label written without its
561
+ // quotes. Single-element arrays are unwrapped ("['Notes']" is the same guess).
562
+ const wanted = norm(Array.isArray(ref) && ref.length === 1 ? ref[0] : ref).replace(/^"|"$/g, '')
563
+ if (!wanted) return null
564
+ const loose = (list, path = []) => {
565
+ for (const node of list) {
566
+ const here = [...path, node.group != null ? `"${node.group}"` : node.slug]
567
+ if (node.group != null) {
568
+ if (norm(node.group) === wanted) return { ref: here }
569
+ } else if (norm(node.slug) === wanted || norm(titles[node.slug] || '') === wanted) {
570
+ return { ref: node.slug }
571
+ }
572
+ const deeper = loose(node.children || [], here)
573
+ if (deeper) return deeper
574
+ }
575
+ return null
576
+ }
577
+ return loose(nodes)
578
  }
579
 
580
  // Markdown bodies arrive under a field name the caller had to guess right.
 
692
  To propose a NEW page, POST a single new-page suggestion with its title and full content:
693
  curl -s "\${AUTH[@]}" -X POST "${HOST}/api/docs/<doc_id>/page-suggestions" -H 'content-type: application/json' \\
694
  -d '{"title": "Page Title", "content_markdown": "# Page Title\\n\\n...", "rationale": "why", "as_agent": "${handle}"}'
695
+ Say WHERE it belongs with "parent" and "index", or it lands at the top level. A parent can
696
+ be a page's slug OR its title, or a group's label; no parent means top level. Index is the
697
+ position among that parent's children (omit for last). The response echoes the parent and
698
+ index it understood, so you can check the page is going where you meant:
699
  -d '{"title": "Setup", "content_markdown": "# Setup\\n\\n...", "parent": ["\\"Getting started\\""], "index": 0, "as_agent": "${handle}"}'
700
  -d '{"title": "Notes on X", "content_markdown": "...", "parent": "research", "as_agent": "${handle}"}'
701
  An unknown parent is refused, so read GET .../structure first. It appears in the sidebar
test/browser.js CHANGED
@@ -501,6 +501,8 @@ async function main() {
501
  await waitFor(async () => (await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 'suggestion applied with marks')
502
  console.log('✓ suggestion accepted -> text replaced with marks')
503
 
 
 
504
  // closed items are hidden by default, shown with the toggle
505
  await waitFor(async () => !(await page.$('.card.suggestion')), 'accepted suggestion hidden')
506
  await page.check('#show-resolved')
@@ -1262,6 +1264,33 @@ async function main() {
1262
  await page.evaluate(() => window.dispatchEvent(new Event('afterprint')))
1263
  await waitFor(async () => page.evaluate(() => !document.getElementById('print-view') && !document.body.classList.contains('printing')), 'print view is torn down afterwards')
1264
  console.log('✓ download: page/project × markdown/pdf, print shows the document alone')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1265
 
1266
  // return to the home page for the remaining playground sub-tests
1267
  await page.evaluate(() => [...document.querySelectorAll('#page-tree a')].find(a => a.textContent.trim() === 'Playground')?.click())
@@ -1370,6 +1399,80 @@ async function main() {
1370
  await page.waitForTimeout(800)
1371
  console.log('✓ link bubble: a plain click offers Open, ctrl+click still follows')
1372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1373
  // ghost page: visiting a slug that does not exist creates it, titled by slug
1374
  const projId = page.url().split('/d/')[1].split('/')[0]
1375
  await page.goto(`${BASE}/d/${projId}/road-map`)
 
501
  await waitFor(async () => (await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 'suggestion applied with marks')
502
  console.log('✓ suggestion accepted -> text replaced with marks')
503
 
504
+
505
+
506
  // closed items are hidden by default, shown with the toggle
507
  await waitFor(async () => !(await page.$('.card.suggestion')), 'accepted suggestion hidden')
508
  await page.check('#show-resolved')
 
1264
  await page.evaluate(() => window.dispatchEvent(new Event('afterprint')))
1265
  await waitFor(async () => page.evaluate(() => !document.getElementById('print-view') && !document.body.classList.contains('printing')), 'print view is torn down afterwards')
1266
  console.log('✓ download: page/project × markdown/pdf, print shows the document alone')
1267
+ // --- the home star must not indent home's title ---
1268
+ // top-level real pages only: a nested page or a proposal is indented on purpose
1269
+ const titleXs = await page.evaluate(() =>
1270
+ [...document.querySelectorAll('#page-tree .page-row[data-depth="0"]:not(.proposal-row)')].map(r => ({
1271
+ title: r.querySelector('a')?.textContent.trim(),
1272
+ left: Math.round(r.querySelector('a')?.getBoundingClientRect().left),
1273
+ star: !!r.querySelector('.home-star'),
1274
+ }))
1275
+ )
1276
+ const starred = titleXs.find(t => t.star)
1277
+ assert.ok(starred, 'the front page still carries its star: ' + JSON.stringify(titleXs))
1278
+ // and it must not land on the caret when home has children of its own
1279
+ const starClash = await page.evaluate(() => {
1280
+ const row = document.querySelector('#page-tree .page-row:has(.home-star)')
1281
+ const star = row?.querySelector('.home-star')
1282
+ const caret = row?.querySelector('.tree-caret')
1283
+ if (!star || !caret) return { bothPresent: false }
1284
+ const l = n => n.getBoundingClientRect().left
1285
+ return { bothPresent: true, apart: Math.abs(l(star) - l(caret)) >= 12 }
1286
+ })
1287
+ if (starClash.bothPresent) assert.ok(starClash.apart, 'star and caret do not sit on top of each other: ' + JSON.stringify(starClash))
1288
+ const others = titleXs.filter(t => !t.star)
1289
+ assert.ok(others.length > 0, 'there are sibling pages to compare against: ' + JSON.stringify(titleXs))
1290
+ for (const other of others) {
1291
+ assert.strictEqual(starred.left, other.left, `"${starred.title}" starts where "${other.title}" does: ` + JSON.stringify(titleXs))
1292
+ }
1293
+ console.log('✓ the star sits in the gutter; every sidebar title starts at the same x')
1294
 
1295
  // return to the home page for the remaining playground sub-tests
1296
  await page.evaluate(() => [...document.querySelectorAll('#page-tree a')].find(a => a.textContent.trim() === 'Playground')?.click())
 
1399
  await page.waitForTimeout(800)
1400
  console.log('✓ link bubble: a plain click offers Open, ctrl+click still follows')
1401
 
1402
+ // --- what you are commenting on stays marked while you type about it ---
1403
+ // Focusing the composer moves focus out of the editor, which drops the
1404
+ // browser's own selection highlight: the text you picked went unmarked while
1405
+ // the box asked you about it.
1406
+ await focusDocEnd(page)
1407
+ await page.keyboard.press('Enter')
1408
+ await page.keyboard.type('A line worth discussing at some length.')
1409
+ await page.waitForTimeout(400)
1410
+ const pickLine = () => page.evaluate(() => {
1411
+ const target = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('A line worth discussing'))
1412
+ const pos = window.__editor.view.posAtDOM(target, 0)
1413
+ window.__editor.commands.setTextSelection({ from: pos, to: pos + target.textContent.length })
1414
+ })
1415
+ await pickLine()
1416
+ await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
1417
+ await page.click('#composer-text')
1418
+ await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .pending-hl').length === 1), 'the pending range is marked once the composer has focus')
1419
+ const pending = await page.evaluate(() => {
1420
+ const n = document.querySelector('.tiptap .pending-hl')
1421
+ const cs = getComputedStyle(n)
1422
+ return { text: n.textContent, bg: cs.backgroundColor, style: cs.borderBottomStyle, nativeSelectionGone: getSelection().isCollapsed }
1423
+ })
1424
+ assert.ok(pending.text.startsWith('A line worth discussing'), 'it marks the text the composer is about: ' + JSON.stringify(pending))
1425
+ assert.notStrictEqual(pending.bg, 'rgba(0, 0, 0, 0)', 'and it is actually visible: ' + JSON.stringify(pending))
1426
+ assert.strictEqual(pending.style, 'dashed', 'dashed, to read as not-yet-committed: ' + JSON.stringify(pending))
1427
+
1428
+ // and it is scaffolding: cancelling must not leave it behind
1429
+ await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click())
1430
+ await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .pending-hl').length === 0), 'cancelling clears the mark')
1431
+ console.log('✓ composer marks the text it is about, and clears it on cancel')
1432
+
1433
+ // --- a comment and a suggestion on the same words ---
1434
+ // Two filled highlights over one range cancelled each other out: the
1435
+ // suggestion's insert/delete spans were invisible under the comment tint.
1436
+ // the box opens when the selection CHANGES, so move the caret before picking
1437
+ // the same line again
1438
+ await focusDocEnd(page)
1439
+ await page.waitForTimeout(200)
1440
+ await pickLine()
1441
+ await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
1442
+ await page.fill('#composer-text', 'can this be tightened?')
1443
+ await page.click('#composer .primary, #composer button:has-text("Comment")')
1444
+ await page.waitForSelector('.card.thread', { timeout: 10000 })
1445
+ await page.click('.card.thread') // active, as it is right after commenting
1446
+ await page.waitForTimeout(300)
1447
+ await page.evaluate(async () => {
1448
+ const id = location.pathname.split('/d/')[1].split('/')[0]
1449
+ const snap = await (await fetch(`/api/docs/${id}`)).json()
1450
+ const idx = (snap.blocks || []).findIndex(b => (b.markdown || '').includes('A line worth discussing'))
1451
+ await fetch(`/api/docs/${id}/suggestions`, {
1452
+ method: 'POST',
1453
+ headers: { 'content-type': 'application/json' },
1454
+ body: JSON.stringify({ block_index: idx, replacement_markdown: 'A line worth debating at some length.', rationale: 'sharper verb' }),
1455
+ })
1456
+ })
1457
+ await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .sugg-ins').length > 0), 'the suggestion reaches the document')
1458
+ const overlap = await page.evaluate(() => {
1459
+ const opaque = c => c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent'
1460
+ const commentHl = [...document.querySelectorAll('.tiptap .comment-hl')]
1461
+ const ins = [...document.querySelectorAll('.tiptap .sugg-ins')]
1462
+ return {
1463
+ commentMarked: commentHl.every(n => n.classList.contains('with-sugg')),
1464
+ commentUnderlined: commentHl.every(n => ['dotted', 'dashed', 'solid'].includes(getComputedStyle(n).borderBottomStyle)),
1465
+ commentFilled: commentHl.some(n => opaque(getComputedStyle(n).backgroundColor)),
1466
+ insFilled: ins.length > 0 && ins.every(n => opaque(getComputedStyle(n).backgroundColor)),
1467
+ insText: ins.map(n => n.textContent.trim()),
1468
+ }
1469
+ })
1470
+ assert.ok(overlap.commentMarked, 'the comment knows it is sharing text with a suggestion: ' + JSON.stringify(overlap))
1471
+ assert.ok(!overlap.commentFilled, 'so it gives up its fill: ' + JSON.stringify(overlap))
1472
+ assert.ok(overlap.commentUnderlined, 'but stays marked by its underline: ' + JSON.stringify(overlap))
1473
+ assert.ok(overlap.insFilled, 'and the inserted words keep a visible fill of their own: ' + JSON.stringify(overlap))
1474
+ console.log('✓ a suggestion stays visible on text that already carries a comment')
1475
+
1476
  // ghost page: visiting a slug that does not exist creates it, titled by slug
1477
  const projId = page.url().split('/d/')[1].split('/')[0]
1478
  await page.goto(`${BASE}/d/${projId}/road-map`)
test/smoke.js CHANGED
@@ -897,6 +897,59 @@ async function main() {
897
  ['notes-on-x'],
898
  'and a page-parented proposal nests under that page: ' + JSON.stringify(placedTree)
899
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900
  console.log('✓ page proposals carry a place in the hierarchy, and accepting files them there')
901
 
902
  // 11i. markdown export: a zip that renders outside cowrite. The images matter
 
897
  ['notes-on-x'],
898
  'and a page-parented proposal nests under that page: ' + JSON.stringify(placedTree)
899
  )
900
+ // An agent works from what it can see, and what it sees is a TITLE. Sending
901
+ // "Research Notes" for the page `research-notes`, or a group's label without
902
+ // its quotes, used to be a 400 — which is how a requested subpage ended up at
903
+ // the top level instead.
904
+ await api('dev:alice', `/api/docs/${npjProj.id}/pages`, { method: 'POST', body: { title: 'Research Notes' } })
905
+ const guesses = [
906
+ ['the exact slug', 'research-notes', 'research-notes'],
907
+ ['the page title', 'Research Notes', 'research-notes'],
908
+ ['the title in another case', 'research notes', 'research-notes'],
909
+ ['a group label without quotes', 'Getting started', ['"Getting started"']],
910
+ ['a one-element array holding a title', ['Research Notes'], 'research-notes'],
911
+ ]
912
+ for (const [what, parent, expected] of guesses) {
913
+ const r = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
914
+ method: 'POST',
915
+ body: { title: `Placed by ${what}`, content_markdown: '# X', parent },
916
+ })
917
+ assert.ok(r.ok, `${what} is accepted as a parent: ${JSON.stringify(r)}`)
918
+ assert.deepEqual(r.parent, expected, `${what} resolves to the canonical ref, and the response says so: ${JSON.stringify(r)}`)
919
+ await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${r.page_suggestion_id}/reject`, { method: 'POST' })
920
+ }
921
+ // the aliases an agent is likely to reach for
922
+ for (const field of ['parent_slug', 'parent_page', 'under']) {
923
+ const r = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
924
+ method: 'POST',
925
+ body: { title: `Via ${field}`, content_markdown: '# X', [field]: 'Research Notes' },
926
+ })
927
+ assert.equal(r.parent, 'research-notes', `"${field}" works as a parent field: ${JSON.stringify(r)}`)
928
+ await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${r.page_suggestion_id}/reject`, { method: 'POST' })
929
+ }
930
+ // a name nothing matches is still refused, with the pages listed to choose from
931
+ const noSuch = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
932
+ method: 'POST',
933
+ body: { title: 'Nowhere', content_markdown: '# X', parent: 'Marketing Plan' },
934
+ })
935
+ assert.ok(noSuch.error?.includes('unknown parent'), 'an unknown parent is refused: ' + JSON.stringify(noSuch))
936
+ assert.ok(noSuch.pages?.includes('research-notes'), 'and the reply lists real pages to choose from: ' + JSON.stringify(noSuch))
937
+ // reading a proposal back shows where it is headed
938
+ const titleParented = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
939
+ method: 'POST',
940
+ body: { title: 'Child By Title', content_markdown: '# Child', parent: 'Research Notes' },
941
+ })
942
+ const readBack = await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${titleParented.page_suggestion_id}`)
943
+ assert.equal(readBack.parent, 'research-notes', 'GET shows the resolved parent: ' + JSON.stringify(readBack))
944
+ await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${titleParented.page_suggestion_id}/accept`, { method: 'POST' })
945
+ const guessTree = (await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).tree
946
+ assert.deepEqual(
947
+ guessTree.find(n => n.slug === 'research-notes')?.children?.map(c => c.slug),
948
+ ['child-by-title'],
949
+ 'and a title-parented proposal lands under that page: ' + JSON.stringify(guessTree)
950
+ )
951
+ console.log('✓ a parent can be named by slug, title or group label — an agent\'s guess is not silently flattened')
952
+
953
  console.log('✓ page proposals carry a place in the hierarchy, and accepting files them there')
954
 
955
  // 11i. markdown export: a zip that renders outside cowrite. The images matter