@marver-design/marver 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/dist/{build-DmfxJc6L.mjs → build-BxGrHFT2.mjs} +1 -1
- package/dist/cli.mjs +5 -5
- package/dist/{collab-BmaOjwP1.mjs → collab-mAiNZlDj.mjs} +232 -43
- package/dist/{comments-pNxjTrGN.mjs → comments-DHB_8BRa.mjs} +3 -3
- package/dist/{daemon-WjonKlPz.mjs → daemon-BChkzDqQ.mjs} +2 -2
- package/dist/{dev-BXq7yE1p.mjs → dev-DLwt3Brb.mjs} +4 -4
- package/dist/{events-C9k-ceOj.mjs → events-B3LBn74P.mjs} +3 -1
- package/dist/{marver-id-gate-DOjnZakC.mjs → marver-id-gate-B2uraTHS.mjs} +8 -5
- package/dist/{plugin-Boy7I_JH.mjs → plugin-DNc4Jpae.mjs} +4 -4
- package/dist/{profile-ChpTPd-X.mjs → profile-BjAPAJSb.mjs} +1 -1
- package/dist/{serve-BeLLizNg.mjs → serve-EjEqsiYa.mjs} +2 -2
- package/dist/{share-C64b1tdN.mjs → share-Gqo_Ygqw.mjs} +14 -15
- package/dist/{summary-Bf9tiGrE.mjs → summary-C7CAypbB.mjs} +1 -1
- package/dist/{sync-WYxDN9IT.mjs → sync-BZaCWqK-.mjs} +1 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +19 -3
- package/src/client/shell/Comments.tsx +94 -17
- package/src/client/shell/comments-store.ts +59 -4
- package/src/client/shell/mentions.ts +108 -1
- package/src/client/shell/sound.ts +32 -0
- package/src/client/shell/store.ts +12 -4
- package/src/client/shell/styles.css +41 -0
- package/src/shared/events.ts +12 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as NAME } from "./cli.mjs";
|
|
2
2
|
import { M as resolveAccess } from "./auth-DdUKeVKt.mjs";
|
|
3
|
-
import { i as loadCollab } from "./sync-
|
|
3
|
+
import { i as loadCollab } from "./sync-BZaCWqK-.mjs";
|
|
4
4
|
//#region src/cli/share.ts
|
|
5
5
|
/**
|
|
6
6
|
* `marver share <action>` - the roster from the terminal (01-sharing §9).
|
|
@@ -9,9 +9,12 @@ import { i as loadCollab } from "./sync-WYxDN9IT.mjs";
|
|
|
9
9
|
* IS the whole interface, and on every canvas it calls the same owner routes
|
|
10
10
|
* the app does, over the device credential `comments connect` stored. The
|
|
11
11
|
* resolver ships with an explainer because a permission system nobody can
|
|
12
|
-
* predict is not safe - `explain`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* predict is not safe - `explain` asks the canvas's OWN `share/explain` route,
|
|
13
|
+
* so its trace is the resolver the doors enforce with (owner role filled,
|
|
14
|
+
* `@domain` explained through a synthetic member, the winning step marked) and
|
|
15
|
+
* cannot drift from the dialog's Who-sees-what (acceptance 9). `who` runs that
|
|
16
|
+
* same pure function locally over the fetched roster for the granted principals
|
|
17
|
+
* it lists - none of them the owner, so no server-only role handling applies.
|
|
15
18
|
*
|
|
16
19
|
* add <who> grant view (default) or --role comment; --expires <iso>
|
|
17
20
|
* remove <who> remove the grant
|
|
@@ -48,19 +51,15 @@ const asStore = (roster) => ({
|
|
|
48
51
|
blocked: roster.blocked,
|
|
49
52
|
grants: roster.grants
|
|
50
53
|
});
|
|
51
|
-
/** The trace
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
email: who.startsWith("@") ? null : who,
|
|
55
|
-
store,
|
|
56
|
-
ceilings
|
|
57
|
-
});
|
|
54
|
+
/** The resolver's trace as the server returned it - the winning step marked with
|
|
55
|
+
* an arrow, the same verdict the dialog's Who-sees-what draws. */
|
|
56
|
+
function renderExplain(r) {
|
|
58
57
|
const lines = [];
|
|
59
|
-
for (const step of r.trace) lines.push(` ${step.where.padEnd(14)} ${step.role.padEnd(9)} ${step.why}`);
|
|
58
|
+
for (const step of r.trace) lines.push(` ${step.win ? "→" : " "} ${step.where.padEnd(14)} ${step.role.padEnd(9)} ${step.why}`);
|
|
60
59
|
const boards = Object.entries(r.boards);
|
|
61
60
|
const readable = boards.filter(([, role]) => role !== "none").length;
|
|
62
61
|
const writable = boards.filter(([, role]) => role === "comment").length;
|
|
63
|
-
for (const [b, role] of boards) lines.push(`
|
|
62
|
+
for (const [b, role] of boards) lines.push(` board ${b.padEnd(14)} ${role}`);
|
|
64
63
|
lines.push(` → ${readable} of ${boards.length} board${boards.length === 1 ? "" : "s"}, may comment on ${writable}`);
|
|
65
64
|
return lines.join("\n");
|
|
66
65
|
}
|
|
@@ -128,8 +127,8 @@ async function shareCommand(root, action, value, opts) {
|
|
|
128
127
|
}
|
|
129
128
|
case "explain": {
|
|
130
129
|
if (!value) throw new Error("explain needs a principal to explain");
|
|
131
|
-
const
|
|
132
|
-
console.log(`\n${renderExplain(
|
|
130
|
+
const r = await api(root, "GET", `share/explain?who=${encodeURIComponent(value)}`);
|
|
131
|
+
console.log(`\n${renderExplain(r)}\n`);
|
|
133
132
|
return;
|
|
134
133
|
}
|
|
135
134
|
case "who": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as replay } from "./events-
|
|
1
|
+
import { n as replay } from "./events-B3LBn74P.mjs";
|
|
2
2
|
import { x as withLock } from "./auth-DdUKeVKt.mjs";
|
|
3
3
|
import { closeSync, fsyncSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
-
import { t as diffEvents } from "./events-
|
|
2
|
+
import { t as diffEvents } from "./events-B3LBn74P.mjs";
|
|
3
3
|
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
|
|
4
4
|
import { t as CLI_TOKEN_CHARS } from "./auth-DdUKeVKt.mjs";
|
|
5
5
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
|
10
10
|
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
11
11
|
import { CommentsController, revealThread } from './Comments.tsx'
|
|
12
12
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
13
|
-
import { useComments } from './comments-store.ts'
|
|
13
|
+
import { avatarFallback, useComments } from './comments-store.ts'
|
|
14
14
|
import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
|
|
15
15
|
|
|
16
16
|
const commentsStore = () => useComments.getState()
|
|
@@ -1169,6 +1169,15 @@ function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
|
|
|
1169
1169
|
)
|
|
1170
1170
|
}
|
|
1171
1171
|
|
|
1172
|
+
/** The pill's face for a human notification - the author's picture, or the
|
|
1173
|
+
* house initials fallback (same ladder the thread avatars use). */
|
|
1174
|
+
function JamAvatar({ author }: { author: { name?: string; avatar?: string; id?: string; email?: string } }) {
|
|
1175
|
+
const { initials, hue } = avatarFallback(author)
|
|
1176
|
+
return author.avatar
|
|
1177
|
+
? <img className="sh-jam-ava" src={author.avatar} alt="" referrerPolicy="no-referrer" />
|
|
1178
|
+
: <span className="sh-jam-ava init" style={{ background: `hsl(${hue} 55% 45%)` }}>{initials}</span>
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1172
1181
|
/** A data-goto whose target frame is not on the current board follows the frame HOME:
|
|
1173
1182
|
* the first curated board (switcher rank) that pins it is switched to and the frame
|
|
1174
1183
|
* focused there - a link is navigation, and navigation never edits a board. Only a
|
|
@@ -1239,11 +1248,18 @@ async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void)
|
|
|
1239
1248
|
* `badge` shows the +N deck count (replacing View); `inert` disables inner clicks (the deck handles it). */
|
|
1240
1249
|
function JamToast({ id, note, badge, inert }: { id: number; note: import('./store.ts').JamNote; badge?: number; inert?: boolean }) {
|
|
1241
1250
|
const dismiss = () => useStore.getState().dismissToast(id)
|
|
1251
|
+
// human pills are person-first: their avatar, "name mentioned you"; the
|
|
1252
|
+
// agent pill keeps its frame-first shape with the mark
|
|
1253
|
+
const human = note.kind && note.author
|
|
1242
1254
|
return (
|
|
1243
1255
|
<div className="sh-toast jam">
|
|
1244
|
-
|
|
1256
|
+
{human
|
|
1257
|
+
? <JamAvatar author={note.author!} />
|
|
1258
|
+
: <span className="sh-jam-mark"><ParallelogramFillIcon size={17} /></span>}
|
|
1245
1259
|
<div className="sh-jam-txt">
|
|
1246
|
-
|
|
1260
|
+
{human
|
|
1261
|
+
? <b className="sh-jam-frame"><span className="t">{note.author!.name ?? 'Someone'}</span> <span className="v">{note.kind === 'mention' ? 'mentioned you' : 'replied'}</span></b>
|
|
1262
|
+
: <b className="sh-jam-frame"><span className="t">{note.frameTitle ?? note.board}</span></b>}
|
|
1247
1263
|
<span className="sh-jam-prev">{note.preview}</span>
|
|
1248
1264
|
</div>
|
|
1249
1265
|
{badge != null
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* top-right stack; the thread card opens beside its pin. Pins keep a screen-space
|
|
5
5
|
* size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
|
|
6
6
|
*/
|
|
7
|
-
import { useEffect, useRef, useState } from 'react'
|
|
7
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import { createPortal } from 'react-dom'
|
|
9
9
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
10
10
|
import { useStore, type Node } from './store.ts'
|
|
@@ -13,7 +13,7 @@ import { canvasCtl } from './canvas/ctl.ts'
|
|
|
13
13
|
import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
|
|
14
14
|
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
|
|
15
15
|
import { Tip } from './Tip.tsx'
|
|
16
|
-
import {
|
|
16
|
+
import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts'
|
|
17
17
|
import { ROUTE } from '../const.ts'
|
|
18
18
|
import type { AgentMeta, Thread } from '../../shared/events.ts'
|
|
19
19
|
|
|
@@ -182,13 +182,15 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
182
182
|
const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
|
|
183
183
|
const flankShim = useStore((s) => s.working.includes(frameId))
|
|
184
184
|
const drafting = draft?.nodeKey === node.key
|
|
185
|
+
const alertIds = useMentionAlerts()
|
|
185
186
|
if (!show || (!open.length && !drafting)) return null
|
|
186
187
|
|
|
187
188
|
// inactive frame: the stack - count + avatars, top-right
|
|
188
189
|
const engaged = selected || open.some((t) => t.id === active) || drafting
|
|
189
190
|
if (!engaged && open.length) {
|
|
190
191
|
return (
|
|
191
|
-
<button className=
|
|
192
|
+
<button className={`cm-stack sh-no-pan${open.some((t) => alertIds.has(t.id)) ? ' mention' : ''}`}
|
|
193
|
+
onClick={(e) => { e.stopPropagation(); useStore.getState().select(node.key); const hit = open.find((t) => alertIds.has(t.id)) ?? open[0]; if (hit) setActive(hit.id) }}>
|
|
192
194
|
<MarkerFace threads={open} />
|
|
193
195
|
</button>
|
|
194
196
|
)
|
|
@@ -234,7 +236,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
234
236
|
const { x, y, orphan } = pinPos(t)
|
|
235
237
|
const isActive = t.id === active
|
|
236
238
|
return (
|
|
237
|
-
<div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}`}
|
|
239
|
+
<div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}${alertIds.has(t.id) ? ' mention' : ''}`}
|
|
238
240
|
style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
|
|
239
241
|
onPointerDown={(e) => e.stopPropagation()}
|
|
240
242
|
onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
|
|
@@ -258,17 +260,51 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
258
260
|
|
|
259
261
|
const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
|
|
260
262
|
|
|
261
|
-
/**
|
|
262
|
-
*
|
|
263
|
+
/** The mentionable set, derived from everything the client has seen (published:
|
|
264
|
+
* projected ids + names; dev: canonical emails). The viewer themself is out -
|
|
265
|
+
* this is the COMPOSER's set (you cannot mention yourself). */
|
|
266
|
+
function usePeople(): MentionPerson[] {
|
|
267
|
+
const events = useComments((s) => s.events)
|
|
268
|
+
const me = useComments((s) => s.me)
|
|
269
|
+
return mentionPeople(events, me ?? undefined)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Everyone, self included - the RENDERING set: a mention of you must light up
|
|
273
|
+
* hardest of all, not disappear (the bug that shipped: the excluded self made
|
|
274
|
+
* your own mentions render as plain text exactly for the person they were for). */
|
|
275
|
+
function useRenderPeople(): { people: MentionPerson[]; isMe: (p: MentionPerson) => boolean } {
|
|
276
|
+
const events = useComments((s) => s.events)
|
|
277
|
+
const me = useComments((s) => s.me)
|
|
278
|
+
const people = useMemo(() => mentionPeople(events), [events])
|
|
279
|
+
const isMe = (p: MentionPerson) =>
|
|
280
|
+
(!!p.id && p.id === me?.id) || (!!p.email && !!me?.email && p.email.toLowerCase() === me.email.toLowerCase())
|
|
281
|
+
return { people, isMe }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Threads holding an unseen mention of me - drives the pin/stack pulse. */
|
|
285
|
+
function useMentionAlerts(): Set<string> {
|
|
286
|
+
const events = useComments((s) => s.events)
|
|
287
|
+
const me = useComments((s) => s.me)
|
|
288
|
+
const seen = useComments((s) => s.mentionSeen)
|
|
289
|
+
return useMemo(() => new Set(mentionAlerts(events, me, seen)), [events, me, seen])
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A comment body with mentions styled. `@marver`: owner-authored → bold accent (a live
|
|
293
|
+
* trigger); anyone else's → plain + a teaching tooltip (context, not a command).
|
|
294
|
+
* `@Person` (v1.1) → the person chip; the same segmentation that derived the
|
|
295
|
+
* event's mentions, so what lit up is what notified. */
|
|
263
296
|
function CommentBody({ body, owner }: { body?: string; owner: boolean }) {
|
|
297
|
+
const { people, isMe } = useRenderPeople()
|
|
264
298
|
if (!body) return null
|
|
265
299
|
return (
|
|
266
300
|
<p className="cm-body">
|
|
267
|
-
{
|
|
268
|
-
? <span key={i}>{s.text}</span>
|
|
269
|
-
:
|
|
270
|
-
? <span key={i}
|
|
271
|
-
:
|
|
301
|
+
{parseBody(body, people).map((s, i) => s.person
|
|
302
|
+
? <span key={i} className={isMe(s.person) ? 'cm-at person me' : 'cm-at person'}>{s.text}</span>
|
|
303
|
+
: !s.marver
|
|
304
|
+
? <span key={i}>{s.text}</span>
|
|
305
|
+
: owner
|
|
306
|
+
? <span key={i} className="cm-at owner">{s.text}</span>
|
|
307
|
+
: <Tip key={i} side="top" label={<span className="cm-at-tip">{AT_TIP}</span>}><span className="cm-at">{s.text}</span></Tip>)}
|
|
272
308
|
</p>
|
|
273
309
|
)
|
|
274
310
|
}
|
|
@@ -339,6 +375,27 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
|
|
|
339
375
|
const ref = useRef<HTMLTextAreaElement>(null)
|
|
340
376
|
const hlRef = useRef<HTMLDivElement>(null)
|
|
341
377
|
const [busy, setBusy] = useState(false)
|
|
378
|
+
// ---- @-mention typeahead (v1.1): the token under the caret filters the people the
|
|
379
|
+
// client has seen; picking completes `@Label ` in place. Escape dismisses THIS token
|
|
380
|
+
// only (the composer keeps its own Escape = cancel semantics when no list is open).
|
|
381
|
+
const people = usePeople()
|
|
382
|
+
const [caret, setCaret] = useState(0)
|
|
383
|
+
const [pick, setPick] = useState(0)
|
|
384
|
+
const [dismissed, setDismissed] = useState<number | null>(null)
|
|
385
|
+
const q = mentionQueryAt(value, caret)
|
|
386
|
+
const matches = q && dismissed !== q.start
|
|
387
|
+
? people.filter((p) => p.label.toLowerCase().startsWith(q.query.toLowerCase())).slice(0, 5)
|
|
388
|
+
: []
|
|
389
|
+
const listOpen = matches.length > 0
|
|
390
|
+
useEffect(() => { setPick(0) }, [q?.start, q?.query])
|
|
391
|
+
const track = () => setCaret(ref.current?.selectionStart ?? 0)
|
|
392
|
+
const choose = (p: MentionPerson) => {
|
|
393
|
+
if (!q) return
|
|
394
|
+
const pos = q.start + p.label.length + 2 // '@' + label + ' '
|
|
395
|
+
onChange(value.slice(0, q.start) + '@' + p.label + ' ' + value.slice(caret))
|
|
396
|
+
setCaret(pos)
|
|
397
|
+
requestAnimationFrame(() => { const el = ref.current; if (el) { el.focus(); el.setSelectionRange(pos, pos) } })
|
|
398
|
+
}
|
|
342
399
|
// +2: scrollHeight excludes the 1px borders, but border-box height includes them - without
|
|
343
400
|
// it every line is squeezed 2px and the single-line pill renders off-center
|
|
344
401
|
const grow = () => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight + 2, 132) + 'px' } }
|
|
@@ -352,18 +409,38 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
|
|
|
352
409
|
}
|
|
353
410
|
return (
|
|
354
411
|
<div className="cm-inputwrap">
|
|
355
|
-
{/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver
|
|
356
|
-
its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted)
|
|
412
|
+
{/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver
|
|
413
|
+
shows its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted)
|
|
414
|
+
and a completed @Person wears the chip it will render with. */}
|
|
357
415
|
<div className="cm-hl" ref={hlRef} aria-hidden>
|
|
358
|
-
{
|
|
359
|
-
? <span key={i} className=
|
|
360
|
-
:
|
|
416
|
+
{parseBody(value, people).map((s, i) => s.person
|
|
417
|
+
? <span key={i} className="cm-at person">{s.text}</span>
|
|
418
|
+
: s.marver
|
|
419
|
+
? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
|
|
420
|
+
: <span key={i}>{s.text}</span>)}
|
|
361
421
|
{value.endsWith('\n') ? '' : ''}
|
|
362
422
|
</div>
|
|
423
|
+
{listOpen && (
|
|
424
|
+
<div className="cm-mentionlist" role="listbox">
|
|
425
|
+
{matches.map((p, i) => (
|
|
426
|
+
<button key={p.id ?? p.email} type="button" className={i === pick ? 'on' : ''} role="option" aria-selected={i === pick}
|
|
427
|
+
onMouseDown={(e) => { e.preventDefault(); choose(p) }} onMouseEnter={() => setPick(i)}>
|
|
428
|
+
<Avatar author={{ ...p, name: p.label }} size={18} /><span>{p.label}</span>
|
|
429
|
+
</button>
|
|
430
|
+
))}
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
363
433
|
<textarea ref={ref} rows={1} value={value} placeholder={placeholder} disabled={busy} spellCheck={false}
|
|
364
|
-
onChange={(e) => onChange(e.target.value)
|
|
434
|
+
onChange={(e) => { onChange(e.target.value); setDismissed(null); setCaret(e.target.selectionStart ?? 0) }}
|
|
435
|
+
onInput={grow} onScroll={syncScroll} onClick={track} onKeyUp={track}
|
|
365
436
|
onKeyDown={(e) => {
|
|
366
437
|
e.stopPropagation()
|
|
438
|
+
if (listOpen) {
|
|
439
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setPick((v) => (v + 1) % matches.length); return }
|
|
440
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setPick((v) => (v + matches.length - 1) % matches.length); return }
|
|
441
|
+
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); choose(matches[pick] ?? matches[0]); return }
|
|
442
|
+
if (e.key === 'Escape') { setDismissed(q!.start); return }
|
|
443
|
+
}
|
|
367
444
|
if (e.key === 'Escape') { onCancel?.(); return }
|
|
368
445
|
if (e.key === 'Enter') {
|
|
369
446
|
if ((e.nativeEvent as unknown as { isComposing?: boolean }).isComposing) return // IME: mid-composition Enter must not send
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { create } from 'zustand'
|
|
8
8
|
import { replay, type CommentEvent, type Thread } from '../../shared/events.ts'
|
|
9
|
+
import { mentionPeople, mentionsIn } from './mentions.ts'
|
|
10
|
+
import { playPing } from './sound.ts'
|
|
9
11
|
import { ROUTE } from '../const.ts'
|
|
10
12
|
|
|
11
13
|
export interface Me {
|
|
@@ -36,6 +38,9 @@ interface CommentsState {
|
|
|
36
38
|
show: boolean // Shift+C - pins visible at all
|
|
37
39
|
showAnchor: boolean // Shift+L - light the tagged ELEMENT while its thread is open
|
|
38
40
|
active: string | null // open thread id
|
|
41
|
+
/** threadId -> when I last opened it; feeds the mention-alert pulse. This
|
|
42
|
+
* browser only (localStorage) - a per-person server mark is v1.2 territory. */
|
|
43
|
+
mentionSeen: Record<string, number>
|
|
39
44
|
// picked, composing. `board` is captured at pick time - the write must land in the
|
|
40
45
|
// log of the board the user was LOOKING at, however long the composer stays open
|
|
41
46
|
draft: { nodeKey: string; frame: string; anchor: unknown; board?: string } | null
|
|
@@ -68,6 +73,9 @@ interface CommentsState {
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
const uuid = () => crypto.randomUUID()
|
|
76
|
+
const loadMentionSeen = (): Record<string, number> => {
|
|
77
|
+
try { return JSON.parse(localStorage.getItem('mv-mention-seen') ?? '{}') } catch { return {} }
|
|
78
|
+
}
|
|
71
79
|
const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
|
|
72
80
|
const api = async (path: string, body?: unknown) => {
|
|
73
81
|
const res = await fetch(`${ROUTE}/api/${path}`, body === undefined ? undefined : {
|
|
@@ -118,6 +126,41 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
118
126
|
}
|
|
119
127
|
})
|
|
120
128
|
}
|
|
129
|
+
// A HUMAN's fresh comment that concerns me raises the same pill the agent
|
|
130
|
+
// gets, person-first: a mention of me anywhere, or a reply in a thread I am
|
|
131
|
+
// part of. Same freshness rules as notifyAgent; one ping per batch, not per
|
|
132
|
+
// event - a burst must not become a drum roll.
|
|
133
|
+
const notifyHuman = (fresh: CommentEvent[]) => {
|
|
134
|
+
const me = get().me
|
|
135
|
+
if (!me) return
|
|
136
|
+
const isMe = (a?: { id?: string; email?: string }) =>
|
|
137
|
+
!!a && ((!!a.id && a.id === me.id) || (!!a.email && !!me.email && a.email.toLowerCase() === me.email.toLowerCase()))
|
|
138
|
+
const news = fresh.filter((e) => !e.agent && (e.type === 'create' || e.type === 'reply') && e.author && !isMe(e.author))
|
|
139
|
+
if (!news.length) return
|
|
140
|
+
const notes: { e: CommentEvent; kind: 'mention' | 'reply' }[] = []
|
|
141
|
+
for (const e of news) {
|
|
142
|
+
if ((e.mentions ?? []).some((m) => isMe(m))) { notes.push({ e, kind: 'mention' }); continue }
|
|
143
|
+
if (e.type !== 'reply' || !e.parentId) continue
|
|
144
|
+
const t = get().threads.find((t) => t.id === e.parentId)
|
|
145
|
+
const mine = !!t && (isMe(t.author) || t.replies.some((r) => r.id !== e.commentId && isMe(r.author)))
|
|
146
|
+
if (mine) notes.push({ e, kind: 'reply' })
|
|
147
|
+
}
|
|
148
|
+
if (!notes.length) return
|
|
149
|
+
playPing()
|
|
150
|
+
void import('./store.ts').then(({ useStore }) => {
|
|
151
|
+
const s = useStore.getState()
|
|
152
|
+
for (const { e, kind } of notes) {
|
|
153
|
+
const threadId = e.type === 'reply' ? e.parentId! : e.commentId!
|
|
154
|
+
const t = get().threads.find((t) => t.id === threadId)
|
|
155
|
+
const entry = t?.frame ? s.manifest?.frames.find((f) => f.id === t.frame) : undefined
|
|
156
|
+
s.jamToast({
|
|
157
|
+
threadId, board: t?.board ?? e.board ?? get().board ?? '', ts: e.ts, kind, author: e.author,
|
|
158
|
+
preview: (e.body ?? '').replace(/\s+/g, ' ').slice(0, 90),
|
|
159
|
+
frame: t?.frame, frameTitle: entry?.title ?? t?.frame, intent: entry?.intent,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
}
|
|
121
164
|
// events are append-only everywhere, so union is ALWAYS safe - the one rule is the
|
|
122
165
|
// baseline, PER BOARD: a board's first successful read must never replay its history
|
|
123
166
|
// as notifications (the switcher list can arrive after the first sweep, so a global
|
|
@@ -131,7 +174,9 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
131
174
|
const fresh = events.filter((e) => !have.has(key(e)) && (have.add(key(e)), true))
|
|
132
175
|
if (!fresh.length) return
|
|
133
176
|
set(derive([...get().events, ...fresh]))
|
|
134
|
-
|
|
177
|
+
const news = fresh.filter((e) => (e.board && baselinedBoards.has(e.board)) || e.ts > bootTs)
|
|
178
|
+
notifyAgent(news)
|
|
179
|
+
notifyHuman(news)
|
|
135
180
|
}
|
|
136
181
|
/** Legacy logs (0.8.0 clients never sent `board`) get it from the endpoint they came
|
|
137
182
|
* from - without it, replies to their threads would route to whatever board is open. */
|
|
@@ -163,6 +208,7 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
163
208
|
|
|
164
209
|
return {
|
|
165
210
|
events: [], threads: [], board: null, me: null, myBoards: null, local: false, connected: false,
|
|
211
|
+
mentionSeen: loadMentionSeen(),
|
|
166
212
|
commentMode: false, show: true, showAnchor: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
|
|
167
213
|
|
|
168
214
|
poke(board) {
|
|
@@ -250,10 +296,11 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
250
296
|
const { draft, me } = get()
|
|
251
297
|
if (!draft || !body.trim()) return
|
|
252
298
|
const id = uuid()
|
|
299
|
+
const mentions = mentionsIn(body.trim(), mentionPeople(get().events, me ?? undefined))
|
|
253
300
|
const ok = await get().send([{
|
|
254
301
|
id: uuid(), ts: Date.now(), type: 'create', commentId: id,
|
|
255
302
|
nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
|
|
256
|
-
author: postAuthor(me), body: body.trim(),
|
|
303
|
+
author: postAuthor(me), body: body.trim(), ...(mentions ? { mentions } : {}),
|
|
257
304
|
}], draft.board)
|
|
258
305
|
if (ok) set({ draft: null, active: id, commentMode: false })
|
|
259
306
|
},
|
|
@@ -264,9 +311,10 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
264
311
|
// route to the thread's ORIGIN log - the viewer may be reading it from another
|
|
265
312
|
// board (frame-scoped display); a reply landing in the wrong log would fork it
|
|
266
313
|
const origin = get().threads.find((t) => t.id === threadId)?.board
|
|
314
|
+
const mentions = mentionsIn(body.trim(), mentionPeople(get().events, get().me ?? undefined))
|
|
267
315
|
return get().send([{
|
|
268
316
|
id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
|
|
269
|
-
author: postAuthor(get().me), body: body.trim(),
|
|
317
|
+
author: postAuthor(get().me), body: body.trim(), ...(mentions ? { mentions } : {}),
|
|
270
318
|
}], origin)
|
|
271
319
|
},
|
|
272
320
|
|
|
@@ -278,7 +326,14 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
278
326
|
setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
|
|
279
327
|
setShow(show) { set({ show }) },
|
|
280
328
|
setShowAnchor(showAnchor) { set({ showAnchor }) },
|
|
281
|
-
setActive(active) {
|
|
329
|
+
setActive(active) {
|
|
330
|
+
// opening a thread is SEEING it - the mention pulse for it stops here
|
|
331
|
+
if (active) {
|
|
332
|
+
const mentionSeen = { ...get().mentionSeen, [active]: Date.now() }
|
|
333
|
+
try { localStorage.setItem('mv-mention-seen', JSON.stringify(mentionSeen)) } catch { /* private mode */ }
|
|
334
|
+
set({ active, mentionSeen })
|
|
335
|
+
} else set({ active })
|
|
336
|
+
},
|
|
282
337
|
setDraft(draft) { set({ draft: draft ? { ...draft, board: draft.board ?? get().board ?? undefined } : null }) },
|
|
283
338
|
dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
|
|
284
339
|
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Mention parsing - pure, so the rendering is unit-testable without
|
|
3
3
|
* pulling the whole comment UI (and its virtual:sh-config) into the test.
|
|
4
|
+
*
|
|
5
|
+
* Two kinds live in a body: `@marver` (the Live Jam trigger, reserved) and
|
|
6
|
+
* person mentions (`@Display Name`, sharing v1.1). ONE segmentation drives
|
|
7
|
+
* highlighting AND the mentions array a send derives - what lights up is
|
|
8
|
+
* exactly what notifies.
|
|
4
9
|
*/
|
|
10
|
+
import type { CommentEvent, Mention } from '../../shared/events.ts'
|
|
5
11
|
|
|
6
12
|
/** Split a comment body into plain text and @marver mention segments (case-insensitive, word-bounded). */
|
|
7
13
|
export function parseMentions(body: string): { text: string; mention: boolean }[] {
|
|
@@ -16,3 +22,104 @@ export function parseMentions(body: string): { text: string; mention: boolean }[
|
|
|
16
22
|
if (last < body.length) out.push({ text: body.slice(last), mention: false })
|
|
17
23
|
return out.length ? out : [{ text: body, mention: false }]
|
|
18
24
|
}
|
|
25
|
+
|
|
26
|
+
/** Someone the composer may name: a distinct author (or past mention target)
|
|
27
|
+
* seen in this canvas's events. `id` on a published canvas (the projected
|
|
28
|
+
* transport), `email` in dev (canonical) - exactly what the event will carry. */
|
|
29
|
+
export interface MentionPerson { label: string; id?: string; email?: string; avatar?: string }
|
|
30
|
+
|
|
31
|
+
const personKey = (p: { id?: string; email?: string }) => p.id ? `i:${p.id}` : `e:${p.email?.toLowerCase()}`
|
|
32
|
+
|
|
33
|
+
/** The mentionable set: named humans from the event log, first-seen order,
|
|
34
|
+
* minus the viewer themself and the reserved agent NAMESPACE - any label
|
|
35
|
+
* STARTING with the word "marver" is out, because the Live Jam daemon's own
|
|
36
|
+
* watcher sees `@marver` inside `@Marver Team` and would run the agent. */
|
|
37
|
+
export function mentionPeople(events: CommentEvent[], self?: { id?: string; email?: string }): MentionPerson[] {
|
|
38
|
+
const seen = new Map<string, MentionPerson>()
|
|
39
|
+
const selfKey = self && (self.id || self.email) ? personKey(self) : null
|
|
40
|
+
const add = (a?: { id?: string; email?: string; name?: string; avatar?: string }, label?: string) => {
|
|
41
|
+
const name = (label ?? a?.name)?.trim()
|
|
42
|
+
if (!a || (!a.id && !a.email) || !name || /^marver\b/i.test(name)) return
|
|
43
|
+
const k = personKey(a)
|
|
44
|
+
if (k !== selfKey && !seen.has(k)) seen.set(k, { label: name, id: a.id, email: a.email, avatar: a.avatar })
|
|
45
|
+
}
|
|
46
|
+
for (const ev of events) {
|
|
47
|
+
if (!ev.agent && (ev.type === 'create' || ev.type === 'reply')) add(ev.author)
|
|
48
|
+
for (const m of ev.mentions ?? []) add(m, m.label)
|
|
49
|
+
}
|
|
50
|
+
return [...seen.values()]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type BodySegment = { text: string; marver?: boolean; person?: MentionPerson }
|
|
54
|
+
|
|
55
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
56
|
+
|
|
57
|
+
/** Segment a body against the known people: `@Label` runs (longest label wins
|
|
58
|
+
* at each position, case-insensitive, trailing-boundary-guarded, and only at
|
|
59
|
+
* a mention START - after whitespace, an opener, or the beginning; `foo@Sam`
|
|
60
|
+
* is an address shape, not a mention, exactly the rule the typeahead uses)
|
|
61
|
+
* plus `@marver` (whose namespace mentionPeople already reserves). */
|
|
62
|
+
export function parseBody(body: string, people: MentionPerson[]): BodySegment[] {
|
|
63
|
+
if (!people.length) return parseMentions(body).map((s) => (s.mention ? { text: s.text, marver: true } : { text: s.text }))
|
|
64
|
+
const byLength = [...people].sort((a, b) => b.label.length - a.label.length)
|
|
65
|
+
const alts = byLength.map((p) => `${escapeRe(p.label)}(?![\\w])`)
|
|
66
|
+
alts.push('marver\\b')
|
|
67
|
+
const pattern = new RegExp(`(?<=^|[\\s(])@(?:${alts.join('|')})`, 'gim')
|
|
68
|
+
const out: BodySegment[] = []
|
|
69
|
+
let last = 0
|
|
70
|
+
for (const m of body.matchAll(pattern)) {
|
|
71
|
+
const i = m.index ?? 0
|
|
72
|
+
if (i > last) out.push({ text: body.slice(last, i) })
|
|
73
|
+
const hit = m[0].slice(1)
|
|
74
|
+
if (/^marver$/i.test(hit)) out.push({ text: m[0], marver: true })
|
|
75
|
+
else out.push({ text: m[0], person: byLength.find((p) => p.label.toLowerCase() === hit.toLowerCase()) })
|
|
76
|
+
last = i + m[0].length
|
|
77
|
+
}
|
|
78
|
+
if (last < body.length) out.push({ text: body.slice(last) })
|
|
79
|
+
return out.length ? out : [{ text: body }]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The mentions a body carries, in event form - derived from the SAME
|
|
83
|
+
* segmentation the highlight uses: what lights up is what notifies. ≤ 8,
|
|
84
|
+
* the validator's cap. An ambiguous label (two people wearing the same
|
|
85
|
+
* display name) mentions EVERY bearer - deterministic and stateless, where
|
|
86
|
+
* remembering which twin was picked would depend on hidden composer state. */
|
|
87
|
+
export function mentionsIn(body: string, people: MentionPerson[]): Mention[] | undefined {
|
|
88
|
+
const out = new Map<string, Mention>()
|
|
89
|
+
const hit = new Set([...parseBody(body, people)].flatMap((s) => (s.person ? [s.person.label.toLowerCase()] : [])))
|
|
90
|
+
for (const p of people) {
|
|
91
|
+
if (out.size >= 8) break
|
|
92
|
+
if (hit.has(p.label.toLowerCase()))
|
|
93
|
+
out.set(personKey(p), p.id ? { id: p.id, label: p.label } : { email: p.email, label: p.label })
|
|
94
|
+
}
|
|
95
|
+
return out.size ? [...out.values()] : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The @-token the caret sits in, for the typeahead: `@` + a partial name
|
|
99
|
+
* (letters, spaces) immediately before `caret`, not already a completed
|
|
100
|
+
* mention. Null when the caret is not in one. */
|
|
101
|
+
export function mentionQueryAt(text: string, caret: number): { start: number; query: string } | null {
|
|
102
|
+
const upto = text.slice(0, caret)
|
|
103
|
+
const m = /(^|[\s(])@([^@\n]{0,40})$/.exec(upto)
|
|
104
|
+
if (!m) return null
|
|
105
|
+
return { start: (m.index ?? 0) + m[1].length, query: m[2] }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Thread ids holding a mention of ME newer than my seen mark - what the pin
|
|
109
|
+
* pulse and unread ring key on. Pure; the store persists `seen` per thread. */
|
|
110
|
+
export function mentionAlerts(
|
|
111
|
+
events: CommentEvent[], me: { id?: string; email?: string } | null | undefined,
|
|
112
|
+
seen: Record<string, number>,
|
|
113
|
+
): string[] {
|
|
114
|
+
if (!me || (!me.id && !me.email)) return []
|
|
115
|
+
const mine = (m: Mention) =>
|
|
116
|
+
(!!m.id && m.id === me.id) || (!!m.email && !!me.email && m.email.toLowerCase() === me.email.toLowerCase())
|
|
117
|
+
const out = new Set<string>()
|
|
118
|
+
for (const ev of events) {
|
|
119
|
+
if (ev.type !== 'create' && ev.type !== 'reply') continue
|
|
120
|
+
if (!(ev.mentions ?? []).some(mine)) continue
|
|
121
|
+
const thread = ev.type === 'reply' ? ev.parentId : ev.commentId
|
|
122
|
+
if (thread && ev.ts > (seen[thread] ?? 0)) out.add(thread)
|
|
123
|
+
}
|
|
124
|
+
return [...out]
|
|
125
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification ping - two soft sine tones, generated, no asset to load.
|
|
3
|
+
* Autoplay policy makes this best-effort by nature: the context only runs
|
|
4
|
+
* after the person has interacted with the page (they are ON the canvas, so
|
|
5
|
+
* in practice it plays). Every failure path is silence, never an error.
|
|
6
|
+
*/
|
|
7
|
+
let ctx: AudioContext | null = null
|
|
8
|
+
|
|
9
|
+
export function playPing(): void {
|
|
10
|
+
try {
|
|
11
|
+
ctx ??= new AudioContext()
|
|
12
|
+
if (ctx.state === 'suspended') {
|
|
13
|
+
// resume() only sticks inside a user gesture; fire-and-forget - if the
|
|
14
|
+
// browser refuses, this ping is silent and the NEXT one plays
|
|
15
|
+
void ctx.resume().catch(() => {})
|
|
16
|
+
if (ctx.state === 'suspended') return
|
|
17
|
+
}
|
|
18
|
+
const t0 = ctx.currentTime
|
|
19
|
+
for (const [freq, at, dur] of [[880, 0, .09], [1174.7, .09, .16]] as const) {
|
|
20
|
+
const osc = ctx.createOscillator()
|
|
21
|
+
const gain = ctx.createGain()
|
|
22
|
+
osc.type = 'sine'
|
|
23
|
+
osc.frequency.value = freq
|
|
24
|
+
gain.gain.setValueAtTime(0, t0 + at)
|
|
25
|
+
gain.gain.linearRampToValueAtTime(.14, t0 + at + .015)
|
|
26
|
+
gain.gain.exponentialRampToValueAtTime(.0001, t0 + at + dur)
|
|
27
|
+
osc.connect(gain).connect(ctx.destination)
|
|
28
|
+
osc.start(t0 + at)
|
|
29
|
+
osc.stop(t0 + at + dur + .02)
|
|
30
|
+
}
|
|
31
|
+
} catch { /* no audio here - fine */ }
|
|
32
|
+
}
|
|
@@ -60,9 +60,16 @@ export interface Node {
|
|
|
60
60
|
* on every fresh navigation. */
|
|
61
61
|
readyRetried?: boolean
|
|
62
62
|
}
|
|
63
|
-
/** A
|
|
64
|
-
*
|
|
65
|
-
|
|
63
|
+
/** A comment notification: a persistent bottom-right glass pill. Three kinds
|
|
64
|
+
* share it - a Marver (agent) reply renders frame-first with the mark; a
|
|
65
|
+
* human `mention`/`reply` renders person-first: their avatar, "«name»
|
|
66
|
+
* mentioned you" / "«name» replied", the message underneath. */
|
|
67
|
+
export interface JamNote {
|
|
68
|
+
threadId: string; board: string; preview: string; ts: number
|
|
69
|
+
frame?: string; frameTitle?: string; intent?: string
|
|
70
|
+
kind?: 'mention' | 'reply' // absent = the agent pill
|
|
71
|
+
author?: { name?: string; avatar?: string; id?: string; email?: string }
|
|
72
|
+
}
|
|
66
73
|
export interface Toast { id: number; text: string; jam?: JamNote }
|
|
67
74
|
|
|
68
75
|
export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string; branding?: boolean } = shConfig
|
|
@@ -1029,7 +1036,8 @@ export const useStore = create<State>((set, get) => {
|
|
|
1029
1036
|
* supersedes), capped so the stack stays small. */
|
|
1030
1037
|
jamToast(note) {
|
|
1031
1038
|
const id = ++toastSeq
|
|
1032
|
-
|
|
1039
|
+
const text = note.kind === 'mention' ? 'You were mentioned' : note.kind === 'reply' ? 'New reply' : 'Marver replied'
|
|
1040
|
+
set((s) => ({ toasts: [...s.toasts.filter((t) => t.jam?.threadId !== note.threadId), { id, text, jam: note }].slice(-8) }))
|
|
1033
1041
|
},
|
|
1034
1042
|
dismissToast(id) { set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) },
|
|
1035
1043
|
clearJamToasts() { set((s) => ({ toasts: s.toasts.filter((t) => !t.jam) })) },
|