Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion desktop/scripts/fix-appimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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*

Expand Down
Original file line number Diff line number Diff line change
@@ -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"],
);
});
66 changes: 66 additions & 0 deletions desktop/src/features/messages/lib/collectMentionPubkeys.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
candidates: readonly MentionCandidate[],
personaMentionNames: ReadonlySet<string> = 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)];
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
buildHighlightPatterns,
findHighlightMatches,
snapPosOutOfAgentMention,
} from "./mentionHighlightExtension.ts";

// ── buildHighlightPatterns ────────────────────────────────────────────
Expand Down Expand Up @@ -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);
});
159 changes: 157 additions & 2 deletions desktop/src/features/messages/lib/mentionHighlightExtension.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand All @@ -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",
Expand Down Expand Up @@ -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,
);
},
},
}),
];
Expand Down Expand Up @@ -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;
}
Loading