diff --git a/desktop/scripts/fix-appimage.sh b/desktop/scripts/fix-appimage.sh index 45f537bb72..23e9002ae1 100755 --- a/desktop/scripts/fix-appimage.sh +++ b/desktop/scripts/fix-appimage.sh @@ -38,6 +38,8 @@ # Fix: remove the offending libs so the app uses the system copies (which are # newer and ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), # and symlink the system GStreamer plugin directory so discovery works correctly. +# Keep bundled libzstd — NixOS/appimage-run hosts often lack libzstd.so.1 on the +# FHS path, and zstd is not part of the Mesa/GLib conflict set above. # No tauri.conf.json knob can do this — bundle.linux.appimage only exposes # bundleMediaFramework, files (copy-only, no remove/symlink), and bundleXdgOpen. @@ -103,7 +105,6 @@ rm -f \ "$LIBDIR"/libselinux.so* \ "$LIBDIR"/libpcre2-8.so* \ "$LIBDIR"/libgst*.so* \ - "$LIBDIR"/libzstd.so* \ "$LIBDIR"/libelf.so* \ "$LIBDIR"/libffi.so* diff --git a/desktop/src/features/messages/lib/collectMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/collectMentionPubkeys.test.mjs new file mode 100644 index 0000000000..8c4e462ad8 --- /dev/null +++ b/desktop/src/features/messages/lib/collectMentionPubkeys.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { collectMentionPubkeys } from "./collectMentionPubkeys.ts"; + +test("explicit mention map wins over same-named channel members", () => { + const map = new Map([["Atlas", "pk-local"]]); + const candidates = [ + { + kind: "identity", + pubkey: "pk-foreign", + displayName: "Atlas", + isMember: true, + isAgent: true, + }, + { + kind: "identity", + pubkey: "pk-local", + displayName: "Atlas", + isMember: true, + isAgent: true, + isManagedAgent: true, + }, + ]; + + assert.deepEqual(collectMentionPubkeys("@Atlas hi", map, candidates), [ + "pk-local", + ]); +}); + +test("same-named members collapse to one pubkey, preferring managed", () => { + const candidates = [ + { + kind: "identity", + pubkey: "pk-foreign", + displayName: "Atlas", + isMember: true, + isAgent: true, + }, + { + kind: "identity", + pubkey: "pk-local", + displayName: "Atlas", + isMember: true, + isAgent: true, + isManagedAgent: true, + }, + ]; + + assert.deepEqual(collectMentionPubkeys("@Atlas hi", new Map(), candidates), [ + "pk-local", + ]); +}); + +test("distinct display names still resolve independently", () => { + const candidates = [ + { + kind: "identity", + pubkey: "pk-a", + displayName: "Alice", + isMember: true, + isAgent: false, + }, + { + kind: "identity", + pubkey: "pk-b", + displayName: "Bob", + isMember: true, + isAgent: true, + }, + ]; + + assert.deepEqual( + collectMentionPubkeys("hey @Alice and @Bob", new Map(), candidates).sort(), + ["pk-a", "pk-b"], + ); +}); diff --git a/desktop/src/features/messages/lib/collectMentionPubkeys.ts b/desktop/src/features/messages/lib/collectMentionPubkeys.ts new file mode 100644 index 0000000000..6ac3d35b51 --- /dev/null +++ b/desktop/src/features/messages/lib/collectMentionPubkeys.ts @@ -0,0 +1,66 @@ +import { hasMention } from "./hasMention"; +import type { MentionCandidate } from "./mentionCandidates"; + +/** + * Resolve `@Name` tokens in `text` to pubkeys. + * + * Explicit composer registrations (`mentionMap`) win. Remaining channel + * members are matched by display name, at most one pubkey per name — when + * two identities share a friendly name (cross-community collision), prefer + * a managed agent so a single `@Name` does not emit duplicate `p` tags. + * + * Names already claimed by persona mentions are skipped so identity + * candidates cannot collide with an in-composer persona token. + */ +export function collectMentionPubkeys( + text: string, + mentionMap: ReadonlyMap, + candidates: readonly MentionCandidate[], + personaMentionNames: ReadonlySet = new Set(), +): string[] { + const pubkeys: string[] = []; + const resolvedNames = new Set( + [...personaMentionNames].map((name) => name.trim().toLowerCase()), + ); + + for (const [displayName, pubkey] of mentionMap) { + if (!hasMention(text, displayName)) { + continue; + } + pubkeys.push(pubkey); + resolvedNames.add(displayName.trim().toLowerCase()); + } + + const ranked = [...candidates].sort((left, right) => { + const rank = (candidate: MentionCandidate) => { + if (candidate.isManagedAgent) return 0; + if (candidate.isAgent) return 1; + return 2; + }; + return rank(left) - rank(right); + }); + + for (const candidate of ranked) { + if (!candidate.pubkey || !candidate.isMember) { + continue; + } + if (pubkeys.includes(candidate.pubkey)) { + continue; + } + const name = candidate.displayName; + if (!name) { + continue; + } + const key = name.trim().toLowerCase(); + if (resolvedNames.has(key)) { + continue; + } + if (!hasMention(text, name)) { + continue; + } + pubkeys.push(candidate.pubkey); + resolvedNames.add(key); + } + + return [...new Set(pubkeys)]; +} diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 164225fd80..bab1387f09 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { buildHighlightPatterns, findHighlightMatches, + snapPosOutOfAgentMention, } from "./mentionHighlightExtension.ts"; // ── buildHighlightPatterns ──────────────────────────────────────────── @@ -164,3 +165,19 @@ test("#general should NOT match inside #generally (trailing word boundary)", () const matches = findHighlightMatches("#generally", patterns); assert.equal(matches.length, 0); }); + +// ── Agent mention caret snap ────────────────────────────────────────── + +test("snapPosOutOfAgentMention leaves boundary positions alone", () => { + const ranges = [{ from: 0, to: 6 }]; // @alice + assert.equal(snapPosOutOfAgentMention(0, ranges), 0); + assert.equal(snapPosOutOfAgentMention(6, ranges), 6); +}); + +test("snapPosOutOfAgentMention ejects the caret from the chip interior", () => { + const ranges = [{ from: 0, to: 6 }]; // @alice + // Just after @ → nearer to start + assert.equal(snapPosOutOfAgentMention(1, ranges), 0); + // Mid-name → nearer to end + assert.equal(snapPosOutOfAgentMention(4, ranges), 6); +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 1fad414084..0c185d6f0a 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -1,6 +1,12 @@ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + Plugin, + PluginKey, + TextSelection, + type EditorState, + type Transaction, +} from "@tiptap/pm/state"; +import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; export const mentionHighlightKey = new PluginKey("mentionHighlight"); @@ -10,6 +16,10 @@ export const mentionHighlightKey = new PluginKey("mentionHighlight"); * * Accepts `names` (display names) and `channelNames` storage options. * On every doc update the plugin scans text nodes and decorates matches. + * + * Agent mentions are treated as atomic for caret placement: the cursor + * cannot rest inside `@AgentName` (which would break the chip when typing). + * Arrow keys and backspace/delete also hop/delete the whole token. */ export const MentionHighlightExtension = Extension.create({ name: "mentionHighlight", @@ -80,10 +90,30 @@ export const MentionHighlightExtension = Extension.create({ return oldDecorations.map(tr.mapping, tr.doc); }, }, + appendTransaction(_transactions, _oldState, newState) { + return snapSelectionOutOfAgentMentions( + newState, + extension.storage.agentNames, + ); + }, props: { decorations(state) { return this.getState(state) ?? DecorationSet.empty; }, + handleClick(view, pos) { + return snapViewSelectionToAgentMentionEdge( + view, + pos, + extension.storage.agentNames, + ); + }, + handleKeyDown(view, event) { + return handleAgentMentionKeyDown( + view, + event, + extension.storage.agentNames, + ); + }, }, }), ]; @@ -328,3 +358,128 @@ function addMatchesForPatterns( } } } + +type MentionRange = { from: number; to: number }; + +/** + * Locate `@AgentName` ranges in a ProseMirror doc for agent display names. + * Exported for unit tests. + */ +export function findAgentMentionRanges( + doc: EditorState["doc"], + agentNames: readonly string[], +): MentionRange[] { + if (agentNames.length === 0) return []; + + const patterns = buildHighlightPatterns([...agentNames], []); + const ranges: MentionRange[] = []; + + doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; + for (const match of findHighlightMatches(node.text, patterns)) { + ranges.push({ from: pos + match.from, to: pos + match.to }); + } + }); + + return ranges; +} + +/** + * Snap a caret position out of an agent-mention interior. + * Prefers the nearer edge; ties go to the end (after the chip). + * Exported for unit tests. + */ +export function snapPosOutOfAgentMention( + pos: number, + ranges: readonly MentionRange[], +): number { + for (const range of ranges) { + if (pos > range.from && pos < range.to) { + const toStart = pos - range.from; + const toEnd = range.to - pos; + return toStart < toEnd ? range.from : range.to; + } + } + return pos; +} + +function snapSelectionOutOfAgentMentions( + state: EditorState, + agentNames: readonly string[], +): Transaction | null { + const { from, to, empty } = state.selection; + if (!empty || from !== to) return null; + + const ranges = findAgentMentionRanges(state.doc, agentNames); + const snapped = snapPosOutOfAgentMention(from, ranges); + if (snapped === from) return null; + + return state.tr.setSelection(TextSelection.create(state.doc, snapped)); +} + +function snapViewSelectionToAgentMentionEdge( + view: EditorView, + pos: number, + agentNames: readonly string[], +): boolean { + const ranges = findAgentMentionRanges(view.state.doc, agentNames); + const snapped = snapPosOutOfAgentMention(pos, ranges); + if (snapped === pos) return false; + + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, snapped)), + ); + return true; +} + +function handleAgentMentionKeyDown( + view: EditorView, + event: KeyboardEvent, + agentNames: readonly string[], +): boolean { + if (event.altKey || event.metaKey || event.ctrlKey) return false; + + const ranges = findAgentMentionRanges(view.state.doc, agentNames); + if (ranges.length === 0) return false; + + const { from, empty } = view.state.selection; + if (!empty) return false; + + if (event.key === "ArrowLeft" && !event.shiftKey) { + const range = ranges.find((candidate) => candidate.to === from); + if (!range) return false; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, range.from), + ), + ); + return true; + } + + if (event.key === "ArrowRight" && !event.shiftKey) { + const range = ranges.find((candidate) => candidate.from === from); + if (!range) return false; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, range.to), + ), + ); + return true; + } + + if (event.key === "Backspace") { + const range = ranges.find((candidate) => candidate.to === from); + if (!range) return false; + view.dispatch(view.state.tr.delete(range.from, range.to)); + return true; + } + + if (event.key === "Delete") { + const range = ranges.find((candidate) => candidate.from === from); + if (!range) return false; + view.dispatch(view.state.tr.delete(range.from, range.to)); + return true; + } + + return false; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..7a03d4852a 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -38,6 +38,7 @@ import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { flushMentionDebounce } from "./flushMentionDebounce"; import { hasMention } from "./hasMention"; import { useDraftMentionRouting } from "./useDraftMentionRouting"; +import { collectMentionPubkeys } from "./collectMentionPubkeys"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { @@ -792,42 +793,13 @@ export function useMentions( ); const extractMentionPubkeys = React.useCallback( - (text: string): string[] => { - const pubkeys: string[] = []; - const selectedDisplayNames = new Set( - [ - ...mentionMapRef.current.keys(), - ...personaMentionMapRef.current.keys(), - ].map((name) => name.trim().toLowerCase()), - ); - - for (const [displayName, pubkey] of mentionMapRef.current) { - if (hasMention(text, displayName)) { - pubkeys.push(pubkey); - } - } - - for (const candidate of mentionCandidates) { - if (!candidate.pubkey) { - continue; - } - if (!candidate.isMember) { - continue; - } - if (pubkeys.includes(candidate.pubkey)) { - continue; - } - const name = candidate.displayName; - if (name && selectedDisplayNames.has(name.trim().toLowerCase())) { - continue; - } - if (name && hasMention(text, name)) { - pubkeys.push(candidate.pubkey); - } - } - - return [...new Set(pubkeys)]; - }, + (text: string): string[] => + collectMentionPubkeys( + text, + mentionMapRef.current, + mentionCandidates, + new Set(personaMentionMapRef.current.keys()), + ), [mentionCandidates], );