diff --git a/i18n/state/de.json b/i18n/state/de.json index 499e980d..6c8495ab 100644 --- a/i18n/state/de.json +++ b/i18n/state/de.json @@ -1940,7 +1940,7 @@ "model": "gemini-4-argon-medium" }, "Included": { - "revision": "9651228e502ce27f41483cf2ccc3981dc18b658813edfb7fc2e809ce701c2ace", + "revision": "36d2f1245edb30d13568a85d11c703ca2962a0c44621349021c045dbccc7af2d", "textHash": "ede237a91a89d50603d77e7574a8fe9adeb0a313b41fee10c23d7b89b1c360cd", "model": "gemini-4-argon-medium" }, @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "3779598aa1a8da95d08bccb6ea4f2e975b54d824da457982b0ec70c51db776fa", + "revision": "e2d7d091eef3fddb7aaafa2dd997bd9adbdd735f524d5fdd329f6b99b1c38fd4", "textHash": "6db42b9f305f630e0bef9b252250cdbd130d816cbf6e99509ac528c8aaafb67d", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "224885714f5d14728cb16b1ac8f28f3618aa81df4a8ccd965b25af9034647f4a", + "revision": "194aa1171302812dc238b2ff80f92de2c217f01233cd38ce0f03cdc7b063195f", "textHash": "49675e72530ddc6833c7ccc687b8b88b4692d0007a8795058803d5c2af6cc1e4", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "4a8b5c513077247fa9a95ac43f20538fde36f361c8ee8c9599afb72382f1109a", + "revision": "c1145afe3f3b26e7334e8045645187d92f020f1736a5baa04b659d7edfc4db71", "textHash": "24bcf53b1ca00a3991724c9258e8dadd3d6743ff248dcb49b0405475365e53fe", "model": "gemini-4-argon-medium" }, diff --git a/i18n/state/es.json b/i18n/state/es.json index 17ebdbca..0d0025fd 100644 --- a/i18n/state/es.json +++ b/i18n/state/es.json @@ -1940,7 +1940,7 @@ "model": "gemini-4-argon-medium" }, "Included": { - "revision": "77be69e6d99c86a589a5cf4f5e14639178a40b0aec8cd7a3f13a23626f68b61e", + "revision": "52219a918bb6bffbe1bb8a3682168a277ecca04ec908a55f6fc6f11c4647cedc", "textHash": "384f88d56ff5e0dd200cb000ee5b997dc721b1c3ec6e1bc2a5fd9a7166ad2939", "model": "gemini-4-argon-medium" }, @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "9014f90bf99b800e554d9e5ae8f9aff9f688b51bd6ce8822860a9a51ddb19890", + "revision": "871c619292a270e8ca33ac9f06da9c70460ebf3ff51ac9e6f5c86b3ba38836cc", "textHash": "ea1fd4940b965ad88ab9f2756750a9e8447a83bde3ca7df57c365c853eaf2bf7", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "f203b657e8ea7155df4a49bc532d5b1c483ffea45232e601057d90bc1ddf359c", + "revision": "104ff25c5642c57146abeb1b04d4f52b91b1f6b71ed6e4ef977bfed5ceb41a80", "textHash": "0521c03f0381925f3956fa71fe3ecf889050b0617024d39818cfc7aec5e33239", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "046e9aface6906b239def2e38dfcfe536ff7aa7d98a848d4a32a66d2bd9b0f64", + "revision": "83dfcb6a3bc61b66a6ff3ec842ab7de12755b6f10f70b39b86c507776c1a36a0", "textHash": "866d97049fb60c20803cc2ec2d32f3054335db509ba6a9291b6352e4cafeb5e6", "model": "gemini-4-argon-medium" }, diff --git a/i18n/state/fr.json b/i18n/state/fr.json index 6d62b6e6..7b269925 100644 --- a/i18n/state/fr.json +++ b/i18n/state/fr.json @@ -1940,7 +1940,7 @@ "model": "gemini-4-argon-medium" }, "Included": { - "revision": "08a7c3d1c19eea68cd41d64f7ce7b7ca98c6f8b3cdf1caec7764476da78f3ab1", + "revision": "cbe428d7bc99a80c5ef18d36c29096ac3fd7689aeaed7968cedf72f9f5153436", "textHash": "f5ecc07b09318f6c1fbfe2a1113dbf9b714071ba32babfd31d21a7cc4df13f7c", "model": "gemini-4-argon-medium" }, @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "b191ed8a54b84f9849d13d1b50eb326d30c5a4097c979d12136d91d77b153f6d", + "revision": "1fd00be2338f019b63d7a25868c31be8f3a5a72f62a02b3e4d72b52e39c234a7", "textHash": "395ed40c5334fb732f277f826884145d5870c0cea7240a0f9a714684bb07f2ef", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "d80a600b097957e3b04d9bf28f8ff6d688af7a435e1482064d42e1ef8ce52bc6", + "revision": "538b3526f2cfcd4d6bd57e220c324d9346ac911bc75308b60b7c948f31c58948", "textHash": "3b06a86147facf90ba173b6dc45de8148c7205a70c1e0d32802d8311b7a05227", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "210ae02bfc1cf2d64492f00c777f6d4f8be7476ecd05bbe505c6c9f8510a5017", + "revision": "67bc664237f377c057098795a7621d73fb9a27d7ce2b6967574fcf322da754c3", "textHash": "08b279e514a4c9a8d0766c8fd3b78e4ea196d283a8ac731ad1b133a6b93bc4c0", "model": "gemini-4-argon-medium" }, diff --git a/i18n/state/it.json b/i18n/state/it.json index ade20a01..d151faac 100644 --- a/i18n/state/it.json +++ b/i18n/state/it.json @@ -1940,7 +1940,7 @@ "model": "gemini-4-argon-medium" }, "Included": { - "revision": "e43d324ddb61ce70554920c42cae7412fcf8f6148d2825715abc31e898443489", + "revision": "b0bbe84250bcbcdea50cc6fb4417ffaee344d3afe49d3753bc6d82e33704978d", "textHash": "d68c6e217afeb716a42f8dc12a81b4bb27b761f6328a65ee50e433ea5a789233", "model": "gemini-4-argon-medium" }, @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "b62546850aa4f6438187d7eae33778e82df6f04cf0f01b51ec393170c25cd3a9", + "revision": "8b9a57285a7dd903647ce885910d7a1c96e69f5974a288cb893683da463e5b45", "textHash": "bfc49a567592b55f313431efe9fd24837628ec79bbc056a516552d8733f11b50", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "cd1bd52a609ed7a57de7a88f2665307769342778f123635cb6cc6d038ab767ca", + "revision": "32f50f37fff2781a66d61755fa57f7fcce01ddbc97165946b52981de6b385ebc", "textHash": "ebfcc14dcf55a2052d66e355a3af89cb054a68f5b81015312bdc3467ebcd970e", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "8f5e20c0fe12a681bd5f98f4ffb89578b349ca37a38ad975c438c260a4532603", + "revision": "f255e2f80fecaed1dab1bf1865ee809e6a5e3cdb5b2c068a5ac8912193e375c3", "textHash": "8c3f20e08d9629a725a1ba084912ab097cc61b1e6f3a8beb16770c8c36771488", "model": "gemini-4-argon-medium" }, diff --git a/i18n/state/ja.json b/i18n/state/ja.json index a8c68a9f..5d2d6004 100644 --- a/i18n/state/ja.json +++ b/i18n/state/ja.json @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "cd116de633704eb1774cd5850374997b79ffc8fd5950212204bc04089e7b2c24", + "revision": "78d6564dfaa68002ee76b8f5880c6d6b781b4086b66444d731d4cd81e0b44182", "textHash": "e3967cb85a440174e9014ea39adc7558e74435e9ff19ea74da7f231092bb1496", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "3c57754dc0020dd0d828091be9af26be736bb3319d4e6cd6962e362999df4045", + "revision": "dc2f59c76783ab01d5fff7ed7fe8ff0b7961669710d4c935093e8c2b4d842962", "textHash": "6c6106046a7ca732fa7f5d729ef5f3d0b031c3120c7638adfa6a04147598b8a9", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "483fed80a506f6aa4c21f63c3d11521903f1811c8549a596968a55b6a13640ad", + "revision": "796c4c4a3097e8f72bde8f100a6440afe213b356b445829a1cccdac27c7aa1be", "textHash": "1a4eef77ec21c7c19577c565908633a31b3efe9a08eb6c7351a9a984ccb13877", "model": "gemini-4-argon-medium" }, diff --git a/i18n/state/ko.json b/i18n/state/ko.json index 9220f84a..bbd37c1b 100644 --- a/i18n/state/ko.json +++ b/i18n/state/ko.json @@ -1940,7 +1940,7 @@ "model": "gemini-4-argon-medium" }, "Included": { - "revision": "4b6b74a44f7f16fcac5c452eedb459f84273fa692cc25d2e111e210400b3e0ad", + "revision": "06f44a65c2158e9b4e0bcb8193b35e79c1d1eceed406d599f3a37a370efa1ccc", "textHash": "8f3da96f4534d1cda98ac09275c91bed6423405a46ad94009225a5e205ad720a", "model": "gemini-4-argon-medium" }, @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "3c7b9641a805fd2bfda00c7ab381fb0461d6c9c25b8cec8eb7d8744ca5b8e42b", + "revision": "5b8c387efdcb1c31b544bd1db37516f970b1ebd7f4248d47f462c0238ac12abf", "textHash": "a1971f4a77fef052a59fbddf0bd632d4da1f25788743513d5f45ef36cb647795", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "5c66857741d34ed07b421bb4cc216a729a91be6d4926879fffaf531149da309a", + "revision": "124745dcb4b9121762c1bffcef97ffa0eb838f42fd5c1214fcb2daad0dbd8806", "textHash": "233edb089af02a4bd83983b570e9fb3b2df48cc206fcb9449963a8581713f329", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "9789468e68ab73b3ecacd6301b1d34c1440bda0142a8aaec16cc3b01553ddaad", + "revision": "59750dd03bfe42c80cfe9972698b469dca098ccfccada6628ee705ee1056007d", "textHash": "642ec5963d33886de6d8e692d10518bedffd2dcc20745a472c4c63bdcf39d20f", "model": "gemini-4-argon-medium" }, diff --git a/i18n/state/pt.json b/i18n/state/pt.json index f74538ca..5a3275b3 100644 --- a/i18n/state/pt.json +++ b/i18n/state/pt.json @@ -1940,7 +1940,7 @@ "model": "gemini-4-argon-medium" }, "Included": { - "revision": "a45dc79a75df0de1527702433f3457584bc1a04a519ba7990faaf2fd9dca795c", + "revision": "6010214dd3e1d30d4fe70b7f4b9b8dab24af5e7d850dc131e619fd6da71f7790", "textHash": "ee052d5adca352e1d5134ef1b653a48853da36ffdc3571cbd4e569731cabdf4d", "model": "gemini-4-argon-medium" }, @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "957a0889eff8e4b0f7b17665ed2df3b059e3398081b21fcf378a2931ecc66dfc", + "revision": "f51e58c03410e5c44410be3a4c5f6145b073ffc80651d78c1405aa9fc1dd5bdb", "textHash": "0e27df7e8679549b6726941effbb7b893bdd6458792690635d0c9952e8d19207", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "1261da2f4d71054a69d0887ff7bf22ea9f5e54389941aca0fcf556f8d060faa1", + "revision": "778e38d78a64cb8566b64639493ce81ad528546727b53582f10c9f44fa915310", "textHash": "afa0b0dc2b0fb741f4653ba57def3c3d916aaa4dc50450f732f62b320ca0d654", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "4e29a91dfc30c5e4bff40bdc8188b529b6207e025c1f234b0f2c1a3109ab1f13", + "revision": "da66f8ef472f1dbf7792ff5f1ee230d9392a17ba9ecf2c9b04ffd820a6d1e498", "textHash": "815fafac2e11d858c534e66a260ed3388a6cad4a5b2c55b86c05502b587ea55c", "model": "gemini-4-argon-medium" }, diff --git a/i18n/state/sv.json b/i18n/state/sv.json index 672a7554..c703b1bb 100644 --- a/i18n/state/sv.json +++ b/i18n/state/sv.json @@ -1940,7 +1940,7 @@ "model": "gemini-4-argon-medium" }, "Included": { - "revision": "fcaf22ae1605ac97eee8c4d6b9806ce139daf91e05622eab7f1e15eb479efe48", + "revision": "6beba7203b02f6d62e0b15e0e2837dd60cccdc83057c2f689045f7f67bf1fae5", "textHash": "61fac422a05fc7b5638f98352f91cdabe69e3af59743ec5fcbf94ce3226c6e93", "model": "gemini-4-argon-medium" }, @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "61bdac64eafd77b70d71bcf184e8c8fb2830e38004808e2c10c25bb60325f6ed", + "revision": "56fcb14a9e598032be5d0477cd1382694f9e48ef32fb57dbd064d6fe26107e1e", "textHash": "36ae74d51bcdd37dc419acef3d0cf5487c4577691020e665d224f9d0ec30dfb4", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "f54ba07e68916fe680186bab8de795dd31d67fa4c5f63a703845e1a0ec2c20ec", + "revision": "0b039532f8de98c2712600e33f6b77d513ee6b8e2c7761e381021f88bb9ce8d9", "textHash": "36fd1207f8ec01ad5168714c5364c59dd65ee599de360e9aae6b2832bb489a6a", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "7cb6278ea92550acc6c8d052aea4ab9a2540bc149d80ec92947f9f1067051e65", + "revision": "71eab0c9f4055deb8bcba941c05ad29137b2333f2bdcf20c04ca90ecce27eee8", "textHash": "3baeef118503461538c57d965c5b595529868e7e8d96e4bae462eb99f8de0626", "model": "gemini-4-argon-medium" }, diff --git a/i18n/state/zh-Hant.json b/i18n/state/zh-Hant.json index ca4bb0e5..5ddeefaa 100644 --- a/i18n/state/zh-Hant.json +++ b/i18n/state/zh-Hant.json @@ -1940,7 +1940,7 @@ "model": "gemini-4-argon-medium" }, "Included": { - "revision": "0bcc98cf3cef87ec9f0a0c75f0751678ca9487639e5e083d21961ab8a572f89a", + "revision": "c7b8a14239caa37d80d8c2296a3e0dbe83b6a0dcddc2c417526a839547ab80c4", "textHash": "5c085a5f02f9c8c0c1d455cacff237f212338085d4861ffa9da335a7a01f23db", "model": "gemini-4-argon-medium" }, @@ -2630,7 +2630,7 @@ "model": "gemini-4-argon-medium" }, "Note deleted.": { - "revision": "ac6a833f27f03963a4127e7da7caac298a429a4c2c17fad92012a287f85594ac", + "revision": "6a44fe323ec53dd1169c385f7e29df946807297ead59bd6f6656ca2b7ff56abb", "textHash": "23e42b509c6c8772347459c8fbbbe3a7e9b08b8477bdfe98fe715fad01761629", "model": "gemini-4-argon-medium" }, @@ -3390,7 +3390,7 @@ "model": "gemini-4-argon-medium" }, "Remove note {n} from task": { - "revision": "015f5aaff49cde8d3f0e8bf41ac8a1fd7f2a9388e06f5a4cae78a811a15c8a43", + "revision": "552215979ed9ae66d1c51aaa5a5d0bb7a5c4683d43d6996dc774bbbb32a2e05f", "textHash": "0012ef0d02ab35ab5b6c7a2adcb171e34ff8a6b0ee62e5056c6a17a93a3c57eb", "model": "gemini-4-argon-medium" }, @@ -3405,7 +3405,7 @@ "model": "gemini-4-argon-medium" }, "Remove suggestion {n} from task": { - "revision": "a1032bdbd19649e59948eeb174b9851c802930d76ee7f739655857e475a1443f", + "revision": "6a2158f9af19523297e53443002228ce0b2a0606679abc0d3d16b8a523dab802", "textHash": "1ca2c5411728457973db97ef04e80b205c5bf26dc49678dcddbda8e90301427d", "model": "gemini-4-argon-medium" }, diff --git a/scripts/check-note-selection.mjs b/scripts/check-note-selection.mjs index 385a9769..c5713d58 100644 --- a/scripts/check-note-selection.mjs +++ b/scripts/check-note-selection.mjs @@ -24,21 +24,36 @@ function note(id, index, group) { let notes = [note('n1', 1), note('n2', 2, 'yellow'), note('n3', 3, 'yellow'), note('n4', 4, 'purple'), note('n5', 5)]; const otherNote = { ...note('other-note', 6), projectId: 'other' }; const original = structuredClone(notes), sent = [], attempts = [], items = new Map(); -let fail = false, hold = false, release, failEdit = false; +let failDelete = false, holdDelete = false, releaseDelete, deletedFixture, restores = 0; +let fail = false, hold = false, release, failEdit = false, holdEdit = false, releaseEdit; await page.route('**/api/**', async route => { const url = new URL(route.request().url()), json = value => route.fulfill({ json: value }); if (url.pathname === '/api/state') return json(snapshot); if (url.pathname === '/api/updates') return json({ currentVersion: 'test', latestVersion: null, phase: 'idle', checkedAt: 1, error: null, canUpdate: false }); if (url.pathname.endsWith('/events')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' }); - if (url.pathname.endsWith('/skills')) return json({ skills: [] }); + if (url.pathname.endsWith('/skills')) return json({ skills: [{ id: 'review', name: 'Review', description: 'Review the paper', hint: '', source: 'builtin', output: 'annotations' }] }); if (url.pathname.endsWith('/git')) return json({ branch: 'main', files: [] }); + if (url.pathname.endsWith('/git/overview')) return json({ worktrees: [], commits: [], truncated: false }); if (url.pathname.endsWith('/manuscript')) return json({ git: { branch: 'main', files: [] }, build: { status: 'ready', key: 'pdf', pages: [[612, 792], [612, 792]] }, annotations: url.pathname.includes('/other/') ? [otherNote] : notes }); if (url.pathname.endsWith('/manuscript.pdf')) return route.fulfill({ contentType: 'application/pdf', body: fs.readFileSync('tests/fixtures/outline.pdf') }); if (url.pathname.endsWith('/manuscript/words')) return json(words[Number(url.searchParams.get('page'))]); if (url.pathname.endsWith('/items')) return json({ items: items.get(url.pathname.split('/')[3]) || [] }); if (url.pathname.endsWith('/note-results')) return json({ notes: [] }); + if (url.pathname.endsWith('/delete')) { + if (failDelete) return route.fulfill({ status: 503, json: { error: 'Could not delete the note.' } }); + if (holdDelete) { holdDelete = false; await new Promise(resolve => { releaseDelete = resolve; }); } + const id = url.pathname.split('/').at(-2); + deletedFixture = notes.find(note => note.id === id); + notes = notes.filter(note => note.id !== id); return json({ ok: true }); + } + if (url.pathname.endsWith('/restore')) { + restores++; + notes.push(deletedFixture); notes.sort((a, b) => a.createdAt - b.createdAt); + return json({ annotations: notes }); + } if (/\/annotations\/[^/]+$/.test(url.pathname)) { if (failEdit) return route.fulfill({ status: 503, json: { error: 'Could not save the note.' } }); + if (holdEdit) { holdEdit = false; await new Promise(resolve => { releaseEdit = resolve; }); } const note = notes.find(note => note.id === url.pathname.split('/').at(-1)); note.comment = route.request().postDataJSON().comment; return json(note); } @@ -75,6 +90,63 @@ async function switchPaper(name) { } try { await page.goto(origin); + // A task's notes are the same cards as My notes, except that × replaces + // "Included ×": it keeps the annotation; trash deletes it everywhere, and ⌘Z + // brings it back into the task. + await notesReady(); await setSelection(check('n1'), true); + const deletingCard = page.locator('.new-task-panel [data-note="n1"]'); + await expect(deletingCard.locator('.pdf-note-card.pdf-note-inline')).toHaveCount(1); + await expect(deletingCard.getByRole('button', { name: 'Edit note 1', exact: true })).toBeVisible(); + await expect(deletingCard.getByRole('button', { name: 'Include note 1 in this task', exact: true })).toHaveCount(0); + await expect(deletingCard.getByText('Included', { exact: true })).toHaveCount(0); + await deletingCard.getByRole('button', { name: 'Remove note 1 from task', exact: true }).click(); + await expect(deletingCard).toHaveCount(0); + assert.ok(notes.some(note => note.id === 'n1')); + await expect(check('n1')).toHaveAttribute('aria-pressed', 'false'); + await setSelection(check('n1'), true); + await page.getByRole('button', { name: 'My notes', exact: true }).click(); + await expect(page.locator('.note-tag[data-note="n1"]')).toHaveCount(1); + failDelete = true; + await deletingCard.getByRole('button', { name: 'Delete note 1', exact: true }).click(); + await expect(deletingCard.getByRole('alert')).toHaveText('Could not delete the note.'); + await expect(page.locator('.note-tag[data-note="n1"]')).toHaveCount(1); + await expect(deletingCard).toBeVisible(); + failDelete = false; holdDelete = true; + await deletingCard.getByRole('button', { name: 'Delete note 1', exact: true }).click(); + await expect.poll(() => Boolean(releaseDelete)).toBe(true); + await expect(deletingCard.getByRole('button', { name: 'Delete note 1', exact: true })).toBeDisabled(); + await expect(send()).toBeDisabled(); + releaseDelete(); + await expect(deletingCard).toHaveCount(0); + await expect(page.locator('.pdf-container [data-note="n1"]')).toHaveCount(0); + await page.keyboard.press('ControlOrMeta+z'); + await expect(deletingCard).toBeVisible(); + await expect(page.locator('.note-tag[data-note="n1"]')).toHaveCount(1); + // Phones have no ⌘Z: the task shows Undo, which also returns the note to the + // task, and clears the deletion so a later ⌘Z does not restore it again. + await deletingCard.getByRole('button', { name: 'Delete note 1', exact: true }).click(); + await expect(deletingCard).toHaveCount(0); + const deleted = page.locator('.task-notes-panel .note-dismissed'); + await expect(deleted).toContainText('Note deleted.'); + const restoresBefore = restores; + await deleted.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(deletingCard).toBeVisible(); + await expect(deleted).toHaveCount(0); + assert.equal(restores, restoresBefore + 1); + await page.locator('.note-selection-head span').click(); + await page.keyboard.press('ControlOrMeta+z'); + await page.waitForTimeout(300); + assert.equal(restores, restoresBefore + 1, 'A later ⌘Z must not replay an undone deletion'); + assert.equal(notes.filter(note => note.id === 'n1').length, 1); + await deletingCard.getByRole('button', { name: 'Delete note 1', exact: true }).click(); + await expect(deletingCard).toHaveCount(0); + await page.reload(); + await expect(page.locator('.note-tag[data-note="n2"]')).toHaveCount(1); + await expect(page.locator('.pdf-container [data-note="n1"]')).toHaveCount(0); + assert.ok(!notes.some(note => note.id === 'n1')); + notes = structuredClone(original); + await page.reload(); + await expect(page.locator('.new-task-panel [data-note]')).toHaveCount(0); await page.getByRole('button', { name: 'Note 1: Revise n1.', exact: true }).click(); const personalCard = page.getByRole('dialog', { name: 'PDF note 1', exact: true }); @@ -151,17 +223,237 @@ try { await expect(send()).toBeDisabled(); await instruction('Purple').fill('Shorten this wording.'); await expect(send()).toBeEnabled(); + // Included notes are edited in place exactly like My notes: the text or ✏️ + // opens the editor; Enter, Escape or a click elsewhere saves. + const taskCard = page.locator('.task-notes-panel [data-note="n1"]'); + const editTaskNote = () => taskCard.getByRole('button', { name: 'Edit note 1', exact: true }); + const taskText = () => taskCard.locator('.note-edit-trigger'); + const taskEditor = taskCard.getByRole('textbox', { name: 'Edit note 1', exact: true }); + await editTaskNote().focus(); await page.keyboard.press('Enter'); + await expect(taskEditor).toBeFocused(); + await expect(taskEditor).toHaveValue('Revise n1.'); + await expect(editTaskNote()).toHaveCount(0); + await expect(taskCard.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(0); + await expect(taskCard.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0); + await expect(send()).toBeDisabled(); + await expect(page.locator('.task-notes-panel').getByRole('button', { name: 'Remove all from task', exact: true })).toBeDisabled(); + await taskEditor.fill(' '); + await expect(taskCard.getByRole('button', { name: 'Remove note 1 from task', exact: true })).toBeDisabled(); + await taskEditor.press('Escape'); + await expect(taskEditor).toHaveCount(0); + await expect(taskText()).toBeFocused(); + assert.equal(notes[0].comment, 'Revise n1.'); + await taskText().click(); + await expect(taskEditor).toBeFocused(); + await taskEditor.fill('Edited from the task panel.'); + await taskEditor.dispatchEvent('keydown', { key: 'Enter', isComposing: true }); + await taskEditor.dispatchEvent('keydown', { key: 'Escape', isComposing: true }); + await expect(taskEditor).toHaveValue('Edited from the task panel.'); + assert.equal(notes[0].comment, 'Revise n1.'); + failEdit = true; + await taskEditor.press('Enter'); + await expect(taskCard.getByRole('alert')).toHaveText('Could not save the note.'); + await expect(taskEditor).toHaveValue('Edited from the task panel.'); + await expect(send()).toBeDisabled(); + failEdit = false; holdEdit = true; + await taskEditor.press('Enter'); + await expect.poll(() => Boolean(releaseEdit)).toBe(true); + await expect(taskEditor).toHaveAttribute('readonly', ''); + await expect(taskCard.getByRole('button', { name: 'Delete note 1', exact: true })).toBeDisabled(); + await expect(send()).toBeDisabled(); + releaseEdit(); + await expect(taskEditor).toHaveCount(0); + await expect(taskCard.getByRole('alert')).toHaveCount(0); + await expect(taskText()).toHaveText('Edited from the task panel.'); + await expect(check('n1')).toHaveAttribute('aria-pressed', 'true'); + await expect(send()).toBeEnabled(); + assert.equal(notes[0].comment, 'Edited from the task panel.'); + // The library shows the same text; both lists are one note. + await expect(page.locator('.notes-panel [data-note="n1"] .note-edit-trigger')).toHaveText('Edited from the task panel.'); + await page.reload(); await notesReady(); + await expect(taskText()).toHaveText('Edited from the task panel.'); + await editTaskNote().click(); + await taskEditor.fill('Saved by Escape.'); await taskEditor.press('Escape'); + await expect(taskText()).toHaveText('Saved by Escape.'); + assert.equal(notes[0].comment, 'Saved by Escape.'); + await editTaskNote().click(); + await taskEditor.fill('Saved by clicking elsewhere.'); + await page.screenshot({ path: 'test-results/task-note-edit.png', fullPage: true, animations: 'disabled' }); + await prompt().click(); + await expect(taskEditor).toHaveCount(0); + await expect(taskText()).toHaveText('Saved by clicking elsewhere.'); + assert.equal(notes[0].comment, 'Saved by clicking elsewhere.'); + // The editor is only the text: the rest of the same card counts as elsewhere. + for (const part of ['.note-quote-text', '.note-where']) { + await editTaskNote().click(); + await taskEditor.fill(`Saved by clicking ${part}.`); + await taskCard.locator(part).click(); + await expect(taskEditor).toHaveCount(0); + await expect(taskText()).toHaveText(`Saved by clicking ${part}.`); + assert.equal(notes[0].comment, `Saved by clicking ${part}.`); + } + await editTaskNote().click(); + await taskEditor.fill('Revise n1.'); + await taskEditor.press('Enter'); + await expect(taskText()).toHaveText('Revise n1.'); + await expect(taskText()).toBeFocused(); + assert.equal(notes[0].comment, 'Revise n1.'); + // Editing in the task, then starting in the library, saves the task's draft + // first; the composer waits until the library's editor closes too. + await editTaskNote().click(); + await taskEditor.fill('Saved when the library editor opens.'); + const otherCard = page.locator('.notes-panel [data-note="n5"]'); + await otherCard.locator('.note-edit-trigger').click(); + await expect(taskEditor).toHaveCount(0); + assert.equal(notes[0].comment, 'Saved when the library editor opens.'); + await expect(send()).toBeDisabled(); + await otherCard.getByRole('textbox', { name: 'Edit note 5', exact: true }).press('Enter'); + await expect(send()).toBeEnabled(); + // While the task's save is still on its way, the same note opened in My notes + // starts from the new text and never writes the old one back. + const libraryN1 = page.locator('.notes-panel [data-note="n1"]'); + const libraryN1Editor = libraryN1.getByRole('textbox', { name: 'Edit note 1', exact: true }); + await editTaskNote().click(); + await taskEditor.fill('Newer text from the task.'); + releaseEdit = undefined; holdEdit = true; + await libraryN1.locator('.note-edit-trigger').click(); + await expect.poll(() => Boolean(releaseEdit)).toBe(true); + await expect(libraryN1Editor).toHaveValue('Newer text from the task.'); + releaseEdit(); + await expect(taskText()).toHaveText('Newer text from the task.'); + await page.mouse.click(130, 400); + await expect(libraryN1Editor).toHaveCount(0); + await expect(libraryN1.locator('.note-edit-trigger')).toHaveText('Newer text from the task.'); + assert.equal(notes[0].comment, 'Newer text from the task.'); + // A failed save puts the old text back everywhere and keeps the draft. + await editTaskNote().click(); + await taskEditor.fill('This save fails.'); + failEdit = true; + await taskEditor.press('Enter'); + await expect(taskCard.getByRole('alert')).toHaveText('Could not save the note.'); + await expect(taskEditor).toHaveValue('This save fails.'); + await expect(libraryN1.locator('.note-edit-trigger')).toHaveText('Newer text from the task.'); + failEdit = false; + await taskEditor.press('Escape'); + await expect(taskText()).toHaveText('This save fails.'); + assert.equal(notes[0].comment, 'This save fails.'); + // Changing side tabs must await the save and preserve a failed draft. Use + // the keyboard as well: it does not trigger the card's pointerdown saver. + for (const tab of ['Contents', 'Git']) { + await editTaskNote().click(); + await taskEditor.fill(`Keep this draft when opening ${tab}.`); + failEdit = true; + await page.getByRole('tab', { name: tab, exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(taskEditor).toHaveValue(`Keep this draft when opening ${tab}.`); + await expect(taskCard.getByRole('alert')).toHaveText('Could not save the note.'); + await expect(page.getByRole('tab', { name: 'New task', exact: true })).toHaveAttribute('aria-selected', 'true'); + failEdit = false; + await page.getByRole('tab', { name: tab, exact: true }).click(); + await expect(page.getByRole('tab', { name: tab, exact: true })).toHaveAttribute('aria-selected', 'true'); + assert.equal(notes[0].comment, `Keep this draft when opening ${tab}.`); + await page.getByRole('tab', { name: 'New task', exact: true }).click(); + await expect(taskText()).toHaveText(`Keep this draft when opening ${tab}.`); + } + // The library's deselection button must also save the task's draft before + // removing the card, including when activated without a pointer event. + await editTaskNote().click(); + await taskEditor.fill('Keep this draft when deselecting elsewhere.'); + failEdit = true; + await taskEditor.press('Enter'); + await expect(taskCard.getByRole('alert')).toHaveText('Could not save the note.'); + await check('n1').focus(); await page.keyboard.press('Enter'); + await expect(taskCard.getByRole('alert')).toHaveText('Could not save the note.'); + await expect(check('n1')).toHaveAttribute('aria-pressed', 'true'); + await expect(taskEditor).toHaveValue('Keep this draft when deselecting elsewhere.'); + failEdit = false; + await check('n1').click(); + await expect(taskCard).toHaveCount(0); + assert.equal(notes[0].comment, 'Keep this draft when deselecting elsewhere.'); + await setSelection(check('n1'), true); + + // Phone navigation unmounts the card. Failed drafts and pending writes still + // belong to the task, so returning must recover the editor and submission lock. + await page.setViewportSize({ width: 390, height: 844 }); + const views = page.getByRole('group', { name: 'Manuscript view' }); + await views.getByRole('button', { name: 'New task', exact: true }).click(); + for (const tab of ['Contents', 'Git']) { + await editTaskNote().click(); + await taskEditor.fill(`Keep the mobile draft across ${tab}.`); + failEdit = true; + await views.getByRole('button', { name: tab, exact: true }).click(); + await views.getByRole('button', { name: 'New task', exact: true }).click(); + await expect(taskEditor).toHaveValue(`Keep the mobile draft across ${tab}.`); + await expect(taskCard.getByRole('alert')).toHaveText('Could not save the note.'); + await expect(send()).toBeDisabled(); + failEdit = false; + await taskEditor.press('ControlOrMeta+Enter'); + await expect(taskEditor).toHaveCount(0); + + await editTaskNote().click(); + await taskEditor.fill(`Wait for the mobile save across ${tab}.`); + releaseEdit = undefined; holdEdit = true; + await views.getByRole('button', { name: tab, exact: true }).click(); + await expect.poll(() => Boolean(releaseEdit)).toBe(true); + await views.getByRole('button', { name: 'New task', exact: true }).click(); + await expect(taskEditor).toHaveValue(`Wait for the mobile save across ${tab}.`); + await expect(taskEditor).toHaveAttribute('readonly', ''); + await expect(send()).toBeDisabled(); + assert.equal(notes[0].comment, `Keep the mobile draft across ${tab}.`); + releaseEdit(); + await expect(taskText()).toHaveText(`Wait for the mobile save across ${tab}.`); + await expect(send()).toBeEnabled(); + } + await page.setViewportSize({ width: 1440, height: 900 }); + + // Review skills hide attachments; returning to the ordinary task recovers + // failed drafts. The hidden editor must not silently unlock submission. + await editTaskNote().click(); + await taskEditor.fill('Keep this draft across the review skill.'); + failEdit = true; + await page.locator('.new-task-panel').getByRole('button', { name: 'Add files or skills', exact: true }).click(); + await page.getByRole('menuitemradio', { name: 'Review', exact: true }).click(); + await expect(taskCard).toHaveCount(0); + await expect(send()).toBeDisabled(); + await page.locator('.new-task-panel').getByRole('button', { name: 'Add skill', exact: true }).click(); + await page.getByRole('menuitemradio', { name: 'Review', exact: true }).click(); + await expect(taskEditor).toHaveValue('Keep this draft across the review skill.'); + await expect(taskCard.getByRole('alert')).toHaveText('Could not save the note.'); + + // A different paper has its own editing state, while this paper's failed + // draft remains available when its annotations finish loading again. + await switchPaper('Other paper'); await notesReady(); + await setSelection(check('other-note'), true); + await expect(send()).toBeEnabled(); + await setSelection(check('other-note'), false); + await switchPaper('Paper'); await notesReady(); + await expect(taskEditor).toHaveValue('Keep this draft across the review skill.'); + await expect(taskCard.getByRole('alert')).toHaveText('Could not save the note.'); + await expect(send()).toBeDisabled(); + failEdit = false; + await taskEditor.press('Enter'); + await expect(taskText()).toHaveText('Keep this draft across the review skill.'); + + // Saving and taking a note out of the task in one click, as in My notes. + await editTaskNote().click(); + await taskEditor.fill('Revise n1.'); + await taskCard.getByRole('button', { name: 'Remove note 1 from task', exact: true }).click(); + await expect(taskCard).toHaveCount(0); + await expect(check('n1')).toHaveAttribute('aria-pressed', 'false'); + assert.equal(notes[0].comment, 'Revise n1.'); + await setSelection(check('n1'), true); // Cards are keyboard operable; editing and locating a note do not change // whether that note will be sent. await check('n1').focus(); await page.keyboard.press('Space'); await expect(check('n1')).toHaveAttribute('aria-pressed', 'false'); await page.keyboard.press('Enter'); await expect(check('n1')).toHaveAttribute('aria-pressed', 'true'); - await page.getByRole('button', { name: 'Edit note 1: Revise n1.', exact: true }).click(); const libraryCard = page.locator('.notes-panel [data-note="n1"]'); + await libraryCard.getByRole('button', { name: 'Edit note 1: Revise n1.', exact: true }).click(); const libraryEditor = libraryCard.getByRole('textbox', { name: 'Edit note 1', exact: true }); await expect(libraryEditor).toBeFocused(); await expect(libraryCard.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(0); + await expect(libraryCard.getByRole('button', { name: 'Edit note 1', exact: true })).toHaveCount(0); await expect(libraryCard.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0); await expect(libraryCard.getByRole('button', { name: 'Add to task', exact: true })).toHaveCount(0); await expect(check('n1')).toHaveAttribute('aria-pressed', 'true'); @@ -176,7 +468,8 @@ try { await expect(libraryEditor).toHaveCount(0); await expect(libraryCard.getByRole('button', { name: 'Edit note 1: Edited directly in the note library.', exact: true })).toBeFocused(); assert.equal(notes[0].comment, 'Edited directly in the note library.'); - await libraryCard.getByText('Edited directly in the note library.', { exact: true }).click(); + await libraryCard.getByRole('button', { name: 'Edit note 1', exact: true }).click(); + await expect(libraryEditor).toBeFocused(); await libraryEditor.fill('Revise n1.'); await check('n1').click(); await expect(libraryEditor).toHaveCount(0); @@ -278,7 +571,7 @@ try { await setSelection(check('n2'), true); await setSelection(check('n4'), true); await expect(instruction('Yellow')).toHaveValue('Explain the reasoning step by step.'); await expect(instruction('Purple')).toHaveValue('Shorten this wording.'); - await page.getByRole('button', { name: 'Agent settings', exact: true }).click(); + await page.locator('.new-task-panel').getByRole('button', { name: 'Agent settings', exact: true }).click(); await page.getByRole('option', { name: / · Claude$/ }).first().click(); await page.keyboard.press('Escape'); await expect(prompt()).toHaveValue(''); @@ -306,14 +599,42 @@ try { await page.getByRole('button', { name: 'Close notes', exact: true }).click(); await page.getByRole('group', { name: 'Manuscript view' }).getByRole('button', { name: 'New task', exact: true }).click(); await expect(page.getByText('Added to task: 1', { exact: true })).toBeVisible(); + const mobileTaskCard = page.locator('.task-notes-panel [data-note="n6"]'); + await mobileTaskCard.getByRole('button', { name: /^Edit note \d+$/ }).click(); + await mobileTaskCard.getByRole('textbox', { name: /^Edit note/ }).fill('Clarify this passage.\n补充说明。'); + await page.screenshot({ path: 'test-results/task-note-edit-mobile.png', fullPage: true, animations: 'disabled' }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390); + // Phones keep Enter for new lines; tapping elsewhere saves. + await page.getByText('Added to task: 1', { exact: true }).click(); + await expect(mobileTaskCard.getByRole('textbox')).toHaveCount(0); await page.screenshot({ path: 'test-results/note-selection-mobile.png', fullPage: true, animations: 'disabled' }); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390); await expect(prompt()).toHaveValue(''); await send().click(); await expect.poll(() => sent.length).toBe(4); assert.deepEqual(sent[3].annotations, ['n6']); + assert.equal(items.get(sent[3].id)[0].meta.notes[0].comment, 'Clarify this passage.\n补充说明。'); assert.equal(sent[3].instructions, ''); assert.equal(sent[3].groupInstructions, undefined); assert.deepEqual(notes, [original[4]]); + // Undo belongs to the task draft it was shown in: sending that task clears it, + // so it can never add the note to the next one. + await page.setViewportSize({ width: 1440, height: 900 }); + await notesReady(); + await setSelection(check('n5'), true); + await page.locator('.task-notes-panel [data-note="n5"]').getByRole('button', { name: /^Delete note/ }).click(); + await expect(page.locator('.task-notes-panel .note-dismissed')).toContainText('Note deleted.'); + await prompt().fill('A question after deleting a note.'); + fail = true; await send().click(); + await expect(page.getByRole('alert').filter({ hasText: 'Try again.' })).toBeVisible(); + await expect(page.locator('.task-notes-panel .note-dismissed')).toContainText('Note deleted.'); + await send().click(); + await expect.poll(() => sent.length).toBe(5); + await expect(page.locator('.task-notes-panel .note-dismissed')).toHaveCount(0); + assert.equal(sent[4].annotations, undefined); + await page.locator('.side-tabs [role="tab"]').first().focus(); + await page.keyboard.press('ControlOrMeta+z'); + await expect(check('n5')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('.task-notes-panel [data-note="n5"]')).toHaveCount(0); assert.deepEqual(errors, []); console.log('PASS: notes default to saved only, explicit selection, direct note sends, inline color-group instructions, persistence, failure/retry, partial sends to different agents, retained group drafts, grouped history, plain questions, and mobile controls.'); -} finally { release?.(); await browser.close(); await server.close(); } +} finally { release?.(); releaseEdit?.(); releaseDelete?.(); await browser.close(); await server.close(); } diff --git a/src/web/main.tsx b/src/web/main.tsx index 70024474..f7666fb4 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -3,7 +3,7 @@ import { GroupCard, GroupInstruction } from './note-groups.tsx'; import { useNoteSelection } from './note-selection.tsx'; import { useGroupInstructions } from './group-instructions.ts'; import { NoteQuote } from './note-content.tsx'; -import { NoteAttachButton, PdfNoteCard } from './pdf-suggestion.tsx'; +import { NoteAttachButton, PdfNoteCard, TaskRemoveButton } from './pdf-suggestion.tsx'; import { StrictMode, Suspense, createContext, lazy, memo, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { createRoot } from 'react-dom/client'; import { flushSync } from 'react-dom'; @@ -634,61 +634,95 @@ interface NoteActions { sending: boolean; } -interface NotesHandle { save: () => Promise } -function NotesPanel({ notes, currentKey, active, actions, onShow, onAnnotate, controller }: { notes: Annotation[]; currentKey?: string; active: string; actions: NoteActions; onShow: (note: Annotation) => void; onAnnotate: (region: boolean) => void; controller: React.Ref }) { - const list = useRef(null); - const [drafts, setDrafts] = useState>({}); +// My notes and the task's notes edit in place the same way: one draft per +// note, saved with Enter, Escape or a click elsewhere; errors stay beside it. +function useNoteCards(notes: Annotation[] | undefined, actions: NoteActions, onEditing?: (editing: boolean) => void, projectId?: string) { + const [drafts, setDrafts] = useState>({}); const draftValues = useRef(drafts); - const [dismissed, setDismissed] = useState(null); - const pending = useRef(new Map>()); - const [busyIds, setBusyIds] = useState([]), [errors, setErrors] = useState>({}); - const editing = Object.keys(drafts).length > 0, busy = busyIds.length > 0, locked = busy || actions.sending; - const onEditing = actions.onEditing; - useEffect(() => { onEditing(editing || busy); return () => onEditing(false); }, [editing, busy, onEditing]); - useEffect(() => { if (active) list.current?.querySelector(`[data-note="${active}"]`)?.scrollIntoView({ block: 'nearest' }); }, [active]); - useEffect(() => { if (dismissed && notes.some(note => note.id === dismissed.id)) setDismissed(null); }, [notes, dismissed]); - useEffect(() => { for (const id of Object.keys(draftValues.current)) if (!notes.some(note => note.id === id)) draft(id, null); }, [notes]); + const [lastDismissed, setDismissed] = useState(null); + const pending = useRef(new Map }>()); + const [busyNotes, setBusyNotes] = useState([]), [failures, setErrors] = useState>({}); + const inProject = (note: Annotation) => projectId === undefined || note.projectId === projectId; + const dismissed = lastDismissed && inProject(lastDismissed) ? lastDismissed : null; + const errors = Object.fromEntries(Object.entries(failures).filter(([, failure]) => inProject(failure.note)).map(([id, failure]) => [id, failure.message])); + const editing = Object.values(drafts).some(entry => inProject(entry.note)), busy = busyNotes.some(inProject); + useEffect(() => { onEditing?.(editing || busy); return () => onEditing?.(false); }, [editing, busy, onEditing]); + useEffect(() => { if (dismissed && notes?.some(note => note.id === dismissed.id)) setDismissed(null); }, [notes, dismissed]); + // Prune only annotations actually removed from a loaded paper, never notes + // merely deselected, hidden by a skill, or belonging to another paper. + useEffect(() => { + if (!notes) return; + for (const [id, entry] of Object.entries(draftValues.current)) if (inProject(entry.note) && !notes.some(note => note.id === id)) draft(id, null); + }, [notes, projectId]); const clearError = (id: string) => setErrors(current => { const next = { ...current }; delete next[id]; return next; }); function draft(id: string, value: string | null) { const next = { ...draftValues.current }; - if (value === null) delete next[id]; else next[id] = value; + if (value === null) delete next[id]; + else { + const note = notes?.find(note => note.id === id) || next[id]?.note; + if (!note) return; + next[id] = { note, value }; + } draftValues.current = next; setDrafts(next); } - function change(id: string, work: () => Promise): Promise { + function change(note: Annotation, work: () => Promise): Promise { + const id = note.id; if (pending.current.has(id) || actions.sending) return Promise.resolve(false); clearError(id); - const operation = Promise.resolve().then(work).then(() => true, e => { setErrors(current => ({ ...current, [id]: e instanceof Error ? e.message : String(e) })); return false; }) - .finally(() => { pending.current.delete(id); setBusyIds([...pending.current.keys()]); }); - pending.current.set(id, operation); setBusyIds([...pending.current.keys()]); + const operation = Promise.resolve().then(work).then(() => true, e => { setErrors(current => ({ ...current, [id]: { note, message: e instanceof Error ? e.message : String(e) } })); return false; }) + .finally(() => { pending.current.delete(id); setBusyNotes([...pending.current.values()].map(entry => entry.note)); }); + pending.current.set(id, { note, promise: operation }); setBusyNotes([...pending.current.values()].map(entry => entry.note)); return operation; } - const saveNote = (note: Annotation, comment: string, quote = false) => change(note.id, async () => { - await actions.edit(note, comment); draft(note.id, null); - if (quote) toggle(note); + const save = (note: Annotation, comment: string, then?: () => void) => change(note, async () => { + await actions.edit(note, comment); draft(note.id, null); then?.(); }); - useImperativeHandle(controller, () => ({ save: async () => { - if (actions.sending || (await Promise.all([...pending.current.values()])).some(saved => !saved)) return false; - for (const [id, value] of Object.entries(draftValues.current)) { - const note = notes.find(note => note.id === id), comment = value.trim(); - if (note && comment && comment !== note.comment) { if (!await saveNote(note, comment)) return false; } + async function saveAll(ids?: string[]) { + const included = (note: Annotation) => inProject(note) && (!ids || ids.includes(note.id)); + if (actions.sending || (await Promise.all([...pending.current.values()].filter(entry => included(entry.note)).map(entry => entry.promise))).some(saved => !saved)) return false; + for (const [id, entry] of Object.entries(draftValues.current)) { + if (!included(entry.note)) continue; + const note = notes?.find(note => note.id === id) || entry.note, comment = entry.value.trim(); + if (comment && comment !== note.comment) { if (!await save(note, comment)) return false; } else draft(id, null); } return true; - } })); + } + // Deleting shows Undo right there, as in the PDF; phones have no ⌘Z. + const remove = (note: Annotation) => change(note, async () => { await actions.remove(note); draft(note.id, null); setDismissed(note); }); + const undo = (disabled: boolean) => dismissed &&
{dismissed.aiReview ? t('Suggestion ignored.') : t('Note deleted.')}
; + // The same card in both lists; `onToggle` adds the note to the task or takes it out. + const card = (note: Annotation, index: number, { currentKey, active, inTask, onShow, onToggle }: { currentKey?: string; active?: string; inTask?: boolean; onShow: (note: Annotation) => void; onToggle: () => void }) =>
  • + item.id === note.id)} error={errors[note.id] || ''} + draft={drafts[note.id]?.value ?? null} onDraftChange={value => { draft(note.id, value); if (value !== null && drafts[note.id] === undefined) clearError(note.id); }} + header={
    {noteWhere(note)}{note.aiReview ? ' · ' + AGENT_LABELS[note.aiReview.agent] : note.author ? ' · ' + t('from {name}', { name: note.author.name }) : ''}
    } + onToggle={onToggle} onDismiss={() => void remove(note)} + onSave={(comment, action) => save(note, comment, action === 'quote' ? onToggle : undefined)}> + {note.key !== currentKey && } +
    +
  • ; + return { editing, busy, errors, dismissed, saveAll, remove, undo, forget: () => setDismissed(current => current && inProject(current) ? null : current), card }; +} + +function useSaveNotesOnLeave(save: () => Promise) { + const saveOnLeave = useRef(save); saveOnLeave.current = save; + useEffect(() => () => { void saveOnLeave.current(); }, []); +} + +interface NotesHandle { save: () => Promise } +function NotesPanel({ notes, currentKey, active, actions, onShow, onAnnotate, controller }: { notes: Annotation[]; currentKey?: string; active: string; actions: NoteActions; onShow: (note: Annotation) => void; onAnnotate: (region: boolean) => void; controller: React.Ref }) { + const list = useRef(null); + const cards = useNoteCards(notes, actions, actions.onEditing); + useSaveNotesOnLeave(cards.saveAll); + const { editing, errors } = cards, locked = cards.busy || actions.sending; + useEffect(() => { if (active) list.current?.querySelector(`[data-note="${active}"]`)?.scrollIntoView({ block: 'nearest' }); }, [active]); + useImperativeHandle(controller, () => ({ save: cards.saveAll })); const selectionLocked = locked || editing; const toggle = (note: Annotation) => actions.selection.set([note.id], !actions.selection.has(note.id)); const selectCard = (note: Annotation, index: number) => toggle(note)}/>; const ownNotes = notes.filter(note => !note.aiReview), ownSelected = ownNotes.filter(note => actions.selection.has(note.id)).length; const unplacedSuggestions = notes.filter(note => note.aiReview && note.key !== currentKey); - const renderNote = (note: Annotation, index: number) =>
  • - { draft(note.id, value); if (value !== null && drafts[note.id] === undefined) clearError(note.id); }} - header={
    {noteWhere(note)}{note.aiReview ? ' · ' + AGENT_LABELS[note.aiReview.agent] : note.author ? ' · ' + t('from {name}', { name: note.author.name }) : ''}
    } - onToggle={() => toggle(note)} onDismiss={() => void change(note.id, async () => { await actions.remove(note); draft(note.id, null); if (note.aiReview) setDismissed(note); })} - onSave={(comment, action) => saveNote(note, comment, action === 'quote')}> - {note.key !== currentKey && } -
    -
  • ; + const renderNote = (note: Annotation, index: number) => cards.card(note, index, { currentKey, active, onShow, onToggle: () => toggle(note) }); return
    @@ -706,42 +740,46 @@ function NotesPanel({ notes, currentKey, active, actions, onShow, onAnnotate, co
    {entries.map(({ note, index }) =>
    {note.quote || t('Marked region')}
    {noteWhere(note)}{note.key !== currentKey && }
    - + {selectCard(note, index)}
    )}
    ; })}
      {notes.map((note, index) => !note.aiReview && !note.group && renderNote(note, index))}
    } {unplacedSuggestions.length > 0 &&

    {t('Unplaced suggestions')}

      {notes.map((note, index) => note.aiReview && note.key !== currentKey && renderNote(note, index))}
    } - {dismissed &&
    {t('Suggestion ignored.')}
    } + {cards.undo(selectionLocked)} {Object.entries(errors).filter(([id]) => !notes.some(note => note.id === id && !note.group)).map(([id, error]) =>

    {error}

    )} ; } // The composer receives only attachments the author explicitly chose in the PDF. -function TaskNotes({ notes, currentKey, actions, onShow }: { notes: Annotation[]; currentKey?: string; actions: NoteActions; onShow: (note: Annotation) => void }) { +// Each one is the same card as in My notes: click the text or ✏️ to edit it, +// 🗑 deletes it (⌘Z brings it back), and × takes it out of the task. +function TaskNotes({ notes, currentKey, actions, onShow, controller, cards }: { notes: Annotation[]; currentKey?: string; actions: NoteActions; onShow: (note: Annotation) => void; controller: React.Ref; cards: ReturnType }) { const panel = useRef(null); const chosen = notes.filter(note => actions.selection.has(note.id)); - if (!chosen.length) return null; + // Saving can outlive this view; drafts, failures and busy state live in App. + useSaveNotesOnLeave(cards.saveAll); + useImperativeHandle(controller, () => ({ save: cards.saveAll })); + const locked = cards.busy || actions.sending, selectionLocked = locked || cards.editing; + // A note that left the task while its save failed still reports it here. + const orphanErrors = Object.entries(cards.errors).filter(([id]) => !chosen.some(note => note.id === id && !note.group)); + if (!chosen.length && !cards.dismissed && !orphanErrors.length) return null; // Notes listed in My notes fly back there; placed AI suggestions stay on the page. const putBack = (list: Annotation[]) => { returnNotes(list.filter(note => !note.aiReview || note.key !== currentKey).flatMap(note => panel.current?.querySelector(`[data-note="${CSS.escape(note.id)}"]`) || [])); actions.selection.set(list.map(note => note.id), false); }; - const remove = (note: Annotation, index: number) => { - const label = note.aiReview ? t('Remove suggestion {n} from task', { n: index + 1 }) : t('Remove note {n} from task', { n: index + 1 }); - return ; - }; + // A location in a color group carries the group's instruction, not its own; + // × sits top right, as on the cards below. const reference = (note: Annotation, index: number) =>
  • -
    {noteWhere(note)}
    +
    {noteWhere(note)} putBack([note])}/>
    - {/* Same control and place as the PDF popover: bottom right, "Included ×". It - floats at the end of the comment, so only the last line makes room. */} -

    {note.comment}{remove(note, index)}

    + {note.comment &&

    {note.comment}

    } {note.key !== currentKey && }
  • ; return
    -
    {t('Added to task: {n}', { n: chosen.length })}
    + {chosen.length > 0 &&
    {t('Added to task: {n}', { n: chosen.length })}
    } {noteGroups(notes).map(({ group, entries }) => { const selected = entries.filter(({ note }) => actions.selection.has(note.id)); return selected.length > 0 && @@ -749,7 +787,9 @@ function TaskNotes({ notes, currentKey, actions, onShow }: { notes: Annotation[]
      {selected.map(({ note, index }) => reference(note, index))}
    ; })} -
      {notes.map((note, index) => !note.group && actions.selection.has(note.id) && reference(note, index))}
    +
      {notes.map((note, index) => !note.group && actions.selection.has(note.id) && cards.card(note, index, { currentKey, inTask: true, onShow, onToggle: () => putBack([note]) }))}
    + {cards.undo(selectionLocked)} + {orphanErrors.map(([id, error]) =>

    {error}

    )}
    ; } @@ -779,11 +819,16 @@ function PendingComparison({ task, busy, onBuild }: { task: Task; busy: boolean; type ManuscriptPanel = 'pdf' | 'task' | 'contents' | 'git'; -function ManuscriptView({ project, data, error, view, setView, region, setRegion, active, setActive, actions, newTask, tools, share, navigation, onGit, writer, reportError, tasks, onOpenTask, onFixProblems }: { onFixProblems?: (request: string) => void; tasks: Task[]; onOpenTask: (id: string) => void; navigation?: React.ReactNode; newTask: (notes: React.ReactNode) => React.ReactNode; tools?: React.ReactNode; share?: React.ReactNode; onGit: (status: GitStatus) => void; writer: CommitWriter; reportError: (error: unknown) => void; project: Project; data: Manuscript | null; error: string; view: ManuscriptPanel; setView: (view: ManuscriptPanel) => void; region: boolean; setRegion: (on: boolean) => void; active: string; setActive: (id: string) => void; actions: NoteActions }) { +function ManuscriptView({ taskCards, project, data, error, view, setView, region, setRegion, active, setActive, actions, newTask, tools, share, navigation, onGit, writer, reportError, tasks, onOpenTask, onFixProblems }: { taskCards: ReturnType; onFixProblems?: (request: string) => void; tasks: Task[]; onOpenTask: (id: string) => void; navigation?: React.ReactNode; newTask: (notes: React.ReactNode) => React.ReactNode; tools?: React.ReactNode; share?: React.ReactNode; onGit: (status: GitStatus) => void; writer: CommitWriter; reportError: (error: unknown) => void; project: Project; data: Manuscript | null; error: string; view: ManuscriptPanel; setView: (view: ManuscriptPanel) => void; region: boolean; setRegion: (on: boolean) => void; active: string; setActive: (id: string) => void; actions: NoteActions }) { const build = data?.build, notes = data?.annotations || [], git = data?.git; const pdf = useRef(null); const [readingNotes, setReadingNotes] = useState(false); const notesPanel = useRef(null); + const taskNotes = useRef(null); + async function switchPanel(next: ManuscriptPanel) { + if (taskNotes.current && !await taskNotes.current.save()) return; + setView(next); + } async function closeReadingNotes() { if (readingNotes && notesPanel.current && !await notesPanel.current.save()) return false; setReadingNotes(false); return true; @@ -859,12 +904,12 @@ function ManuscriptView({ project, data, error, view, setView, region, setRegion {!hidden && togglePanel}
    - - + +
    {!hidden && sideBySide && share} - {side === 'task' ? newTask() : side === 'contents' ?