Spaces:
Sleeping
Sleeping
Merge pull request #11 from huggingface/bugfixes
Browse filesFour fixes: agent subpages, the pending selection, overlapping marks, the star
- client/app.css +18 -1
- client/src/doc.js +54 -9
- server/api.js +67 -17
- test/browser.js +103 -0
- 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
await collab.ensureStructurePage(req.params.id)
|
| 189 |
const structure = getProjectStructure(hocuspocus, req.params.id)
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 520 |
-
// is its path array (['design', '"Ideas"']) — see pages.js.
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
}
|
| 528 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 647 |
-
page
|
| 648 |
-
|
|
|
|
| 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
|