letagents 0.12.34 → 0.12.35
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/README.md +0 -2
- package/dist/mcp/server/runtime/messages.js +18 -1
- package/package.json +1 -1
- package/shared/code-block.css +193 -0
- package/shared/code-highlighting.d.mts +109 -0
- package/shared/code-highlighting.mjs +345 -0
- package/shared/codex-owner-isolation.mjs +1 -1
- package/shared/message-link-preview-store.d.mts +10 -0
- package/shared/message-link-preview-store.mjs +77 -0
- package/shared/message-link-previews.d.mts +16 -0
- package/shared/message-link-previews.mjs +70 -0
- package/shared/message-pin-store.d.mts +20 -0
- package/shared/message-pin-store.mjs +102 -0
- package/shared/message-pins.d.mts +25 -0
- package/shared/message-pins.mjs +16 -0
- package/shared/message-reaction-store.d.mts +51 -0
- package/shared/message-reaction-store.mjs +290 -0
- package/shared/message-reactions.d.mts +73 -0
- package/shared/message-reactions.mjs +130 -0
- package/shared/message-search-controller.d.mts +38 -0
- package/shared/message-search-controller.mjs +99 -0
- package/shared/message-search.d.mts +46 -0
- package/shared/message-search.mjs +114 -0
- package/shared/room-message-urls.d.mts +25 -0
- package/shared/room-message-urls.mjs +119 -0
- package/shared/room-notification-client.d.mts +18 -0
- package/shared/room-notification-client.mjs +140 -0
- package/shared/room-notification-preferences.d.mts +17 -0
- package/shared/room-notification-preferences.mjs +34 -0
- package/shared/room-resource-invalidation.d.mts +5 -0
- package/shared/room-resource-invalidation.mjs +6 -0
- package/shared/room-typing.d.mts +16 -0
- package/shared/room-typing.mjs +112 -0
- package/shared/room-unread-client.ts +128 -0
- package/shared/room-unread.d.mts +25 -0
- package/shared/room-unread.mjs +57 -0
- package/shared/room-unread.test.mjs +91 -0
- package/shared/rooms/RoomsDirectory.vue +1 -1
- package/shared/rooms/directory.ts +1 -0
- package/shared/slash-commands.d.mts +31 -0
- package/shared/slash-commands.mjs +48 -0
- package/shared/slash-commands.test.mjs +26 -0
- package/shared/ui/MessageReactionBar.vue +65 -0
- package/shared/ui/MessageReactionPicker.vue +291 -0
- package/shared/ui/MessageSearchResults.vue +106 -0
- package/shared/ui/PinnedMessages.vue +124 -0
- package/shared/ui/TypingIndicator.vue +22 -0
- package/shared/ui/message-reactions.css +254 -0
- package/shared/ui/message-search.css +186 -0
- package/shared/ui/useComposerSlashCommands.ts +92 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { computed, nextTick, onMounted, onScopeDispose, ref, watch, type Ref, type InjectionKey } from "vue";
|
|
2
|
+
import { canClearUnreadBookmark, createRoomUnreadStore } from "./room-unread.mjs";
|
|
3
|
+
|
|
4
|
+
export function createRoomUnreadClient(namespace: string) {
|
|
5
|
+
const account = ref<string | null>(null);
|
|
6
|
+
const revision = ref(0);
|
|
7
|
+
const visit = ref({ room: "", account: null as string | null, revision: null as string | null, generation: 0, dividerId: null as string | null });
|
|
8
|
+
const store = createRoomUnreadStore({
|
|
9
|
+
namespace, storage: () => window.localStorage,
|
|
10
|
+
changed: () => { revision.value++; },
|
|
11
|
+
});
|
|
12
|
+
if (typeof window !== "undefined") window.addEventListener("storage", event => {
|
|
13
|
+
if (store.acceptsStorageEvent(event.key)) revision.value++;
|
|
14
|
+
});
|
|
15
|
+
function enter(room: string | null | undefined) {
|
|
16
|
+
room = (room ?? "").trim().toLowerCase();
|
|
17
|
+
if (visit.value.room === room && visit.value.account === account.value) return;
|
|
18
|
+
visit.value = { room, account: account.value, revision: store.get(account.value, room)?.revision ?? null, generation: visit.value.generation + 1, dividerId: null };
|
|
19
|
+
}
|
|
20
|
+
watch(account, () => enter(visit.value.room), { flush: "sync" });
|
|
21
|
+
return {
|
|
22
|
+
account, visit, enter,
|
|
23
|
+
get(room: string | null | undefined) {
|
|
24
|
+
void revision.value;
|
|
25
|
+
return store.get(account.value, room);
|
|
26
|
+
},
|
|
27
|
+
mark: (room: string | null | undefined, messageId: string) => store.mark(account.value, room, messageId),
|
|
28
|
+
clear: (room: string | null | undefined, expected: string) => store.clear(account.value, room, expected),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export type RoomUnreadClient = ReturnType<typeof createRoomUnreadClient>;
|
|
32
|
+
export const unreadMenuKey: InjectionKey<{ client: RoomUnreadClient; room: Readonly<Ref<string | null | undefined>> }> = Symbol("room-unread-menu");
|
|
33
|
+
|
|
34
|
+
export function useUnreadTimeline(options: {
|
|
35
|
+
client: RoomUnreadClient;
|
|
36
|
+
room: Readonly<Ref<string | null | undefined>>;
|
|
37
|
+
active: Readonly<Ref<boolean>>;
|
|
38
|
+
ready: Readonly<Ref<boolean>>;
|
|
39
|
+
element: Ref<HTMLElement | null>;
|
|
40
|
+
reveal(messageId: string): Promise<boolean>;
|
|
41
|
+
bottom(reading?: boolean): void;
|
|
42
|
+
}) {
|
|
43
|
+
const { client } = options;
|
|
44
|
+
const bookmark = computed(() => client.get(options.room.value));
|
|
45
|
+
const enteredRevision = ref<string | null>(null);
|
|
46
|
+
const revealed = ref(false);
|
|
47
|
+
let generation = 0;
|
|
48
|
+
let started = false;
|
|
49
|
+
let reading = false;
|
|
50
|
+
let automaticScroll = false;
|
|
51
|
+
let mounted = false;
|
|
52
|
+
const dividerId = computed(() => client.visit.value.room === (options.room.value ?? "").trim().toLowerCase()
|
|
53
|
+
? client.visit.value.dividerId : null);
|
|
54
|
+
|
|
55
|
+
function checkRead() {
|
|
56
|
+
const el = options.element.value;
|
|
57
|
+
if (!el || !options.active.value || automaticScroll) return;
|
|
58
|
+
if (canClearUnreadBookmark({
|
|
59
|
+
enteredRevision: enteredRevision.value, current: bookmark.value, revealed: revealed.value,
|
|
60
|
+
atBottom: el.scrollHeight - el.clientHeight - el.scrollTop < 60,
|
|
61
|
+
visible: document.visibilityState === "visible", focused: document.hasFocus(),
|
|
62
|
+
})) client.clear(options.room.value, enteredRevision.value!);
|
|
63
|
+
}
|
|
64
|
+
async function open() {
|
|
65
|
+
if (!mounted || started || !options.active.value || !options.ready.value) return;
|
|
66
|
+
started = true;
|
|
67
|
+
const mark = bookmark.value;
|
|
68
|
+
if (!mark || mark.revision !== enteredRevision.value) return;
|
|
69
|
+
const currentGeneration = generation;
|
|
70
|
+
const found = await options.reveal(mark.messageId);
|
|
71
|
+
if (generation !== currentGeneration || bookmark.value?.revision !== mark.revision) return;
|
|
72
|
+
revealed.value = found;
|
|
73
|
+
automaticScroll = false;
|
|
74
|
+
if (found) client.visit.value.dividerId = mark.messageId;
|
|
75
|
+
await nextTick();
|
|
76
|
+
if (generation !== currentGeneration) return;
|
|
77
|
+
if (!found) options.bottom();
|
|
78
|
+
else checkRead(); // Later visit already at the bottom, visible and focused.
|
|
79
|
+
}
|
|
80
|
+
watch(() => [options.room.value, client.visit.value.generation, options.active.value], () => {
|
|
81
|
+
generation++;
|
|
82
|
+
enteredRevision.value = options.active.value
|
|
83
|
+
&& client.visit.value.room === (options.room.value ?? "").trim().toLowerCase()
|
|
84
|
+
? client.visit.value.revision : null;
|
|
85
|
+
revealed.value = false;
|
|
86
|
+
started = false;
|
|
87
|
+
reading = false;
|
|
88
|
+
automaticScroll = false;
|
|
89
|
+
void nextTick(open);
|
|
90
|
+
}, { immediate: true, flush: "sync" });
|
|
91
|
+
watch(options.ready, () => { void nextTick(open); });
|
|
92
|
+
|
|
93
|
+
function input(event: Event) {
|
|
94
|
+
if (event instanceof KeyboardEvent && !["ArrowDown", "ArrowUp", "PageDown", "PageUp", "End", "Home", " "].includes(event.key)) return;
|
|
95
|
+
reading = true;
|
|
96
|
+
automaticScroll = false;
|
|
97
|
+
void nextTick(() => { if (reading) checkRead(); });
|
|
98
|
+
}
|
|
99
|
+
function scroll() { if (reading) checkRead(); }
|
|
100
|
+
function scrollEnd() { if (reading) checkRead(); reading = false; }
|
|
101
|
+
function programmaticScroll() { reading = false; automaticScroll = true; }
|
|
102
|
+
function jumpToLatest() {
|
|
103
|
+
options.bottom(true);
|
|
104
|
+
reading = true;
|
|
105
|
+
automaticScroll = false;
|
|
106
|
+
void nextTick(() => { if (reading) checkRead(); });
|
|
107
|
+
}
|
|
108
|
+
onMounted(() => {
|
|
109
|
+
mounted = true;
|
|
110
|
+
const el = options.element.value;
|
|
111
|
+
for (const event of ["wheel", "touchmove", "pointerdown", "keydown"]) el?.addEventListener(event, input, { passive: true });
|
|
112
|
+
el?.addEventListener("scroll", scroll);
|
|
113
|
+
el?.addEventListener("scrollend", scrollEnd);
|
|
114
|
+
window.addEventListener("focus", checkRead);
|
|
115
|
+
document.addEventListener("visibilitychange", checkRead);
|
|
116
|
+
void nextTick(open);
|
|
117
|
+
});
|
|
118
|
+
onScopeDispose(() => {
|
|
119
|
+
generation++;
|
|
120
|
+
const el = options.element.value;
|
|
121
|
+
for (const event of ["wheel", "touchmove", "pointerdown", "keydown"]) el?.removeEventListener(event, input);
|
|
122
|
+
el?.removeEventListener("scroll", scroll);
|
|
123
|
+
el?.removeEventListener("scrollend", scrollEnd);
|
|
124
|
+
window.removeEventListener("focus", checkRead);
|
|
125
|
+
document.removeEventListener("visibilitychange", checkRead);
|
|
126
|
+
});
|
|
127
|
+
return { dividerId, programmaticScroll, jumpToLatest };
|
|
128
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface RoomUnreadBookmark {
|
|
2
|
+
room: string;
|
|
3
|
+
messageId: string;
|
|
4
|
+
revision: string;
|
|
5
|
+
markedAt: number;
|
|
6
|
+
}
|
|
7
|
+
export function unreadRoomKey(room: string | null | undefined): string;
|
|
8
|
+
export function createRoomUnreadStore(options: {
|
|
9
|
+
storage(): Pick<Storage, "getItem" | "setItem">;
|
|
10
|
+
namespace: string;
|
|
11
|
+
changed?(): void;
|
|
12
|
+
}): {
|
|
13
|
+
get(account: string | null, room: string | null | undefined): RoomUnreadBookmark | null;
|
|
14
|
+
mark(account: string | null, room: string | null | undefined, messageId: string): RoomUnreadBookmark | null;
|
|
15
|
+
clear(account: string | null, room: string | null | undefined, revision: string): boolean;
|
|
16
|
+
acceptsStorageEvent(key: string | null): boolean;
|
|
17
|
+
};
|
|
18
|
+
export function canClearUnreadBookmark(input: {
|
|
19
|
+
enteredRevision: string | null;
|
|
20
|
+
current: RoomUnreadBookmark | null;
|
|
21
|
+
revealed: boolean;
|
|
22
|
+
atBottom: boolean;
|
|
23
|
+
visible: boolean;
|
|
24
|
+
focused: boolean;
|
|
25
|
+
}): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const MAX_ROOMS = 500;
|
|
2
|
+
export const unreadRoomKey = (room) => String(room ?? "").trim().toLowerCase();
|
|
3
|
+
|
|
4
|
+
// This is a personal local bookmark, never receipt or notification evidence.
|
|
5
|
+
// Resolve storage inside each try block: even the localStorage getter can throw.
|
|
6
|
+
export function createRoomUnreadStore({ storage, namespace, changed = () => {} }) {
|
|
7
|
+
const key = (account) => `${namespace}:${encodeURIComponent(account)}`;
|
|
8
|
+
function read(account) {
|
|
9
|
+
if (!account) return [];
|
|
10
|
+
try {
|
|
11
|
+
const rows = JSON.parse(storage().getItem(key(account)) || "[]");
|
|
12
|
+
return Array.isArray(rows) ? rows.filter(row => row
|
|
13
|
+
&& typeof row.room === "string" && row.room
|
|
14
|
+
&& typeof row.messageId === "string" && row.messageId
|
|
15
|
+
&& typeof row.revision === "string" && row.revision
|
|
16
|
+
&& Number.isFinite(row.markedAt)).slice(-MAX_ROOMS) : [];
|
|
17
|
+
} catch { return []; }
|
|
18
|
+
}
|
|
19
|
+
function write(account, rows) {
|
|
20
|
+
try {
|
|
21
|
+
storage().setItem(key(account), JSON.stringify(rows.slice(-MAX_ROOMS)));
|
|
22
|
+
changed();
|
|
23
|
+
return true;
|
|
24
|
+
} catch { changed(); return false; }
|
|
25
|
+
}
|
|
26
|
+
function get(account, room) {
|
|
27
|
+
return read(account).find(row => row.room === unreadRoomKey(room)) ?? null;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
get,
|
|
31
|
+
mark(account, room, messageId) {
|
|
32
|
+
room = unreadRoomKey(room);
|
|
33
|
+
if (!account || !room || !messageId || messageId.startsWith("pending:")) return null;
|
|
34
|
+
const markedAt = Date.now();
|
|
35
|
+
let revision = `${markedAt}-${Math.random()}`;
|
|
36
|
+
try { revision = globalThis.crypto?.randomUUID?.() ?? revision; } catch { /* Plain HTTP may lack secure crypto. */ }
|
|
37
|
+
const row = { room, messageId, markedAt, revision };
|
|
38
|
+
return write(account, [...read(account).filter(item => item.room !== room), row]) ? row : null;
|
|
39
|
+
},
|
|
40
|
+
clear(account, room, revision) {
|
|
41
|
+
const rows = read(account);
|
|
42
|
+
const current = rows.find(row => row.room === unreadRoomKey(room));
|
|
43
|
+
if (!current || current.revision !== revision) return false;
|
|
44
|
+
return write(account, rows.filter(row => row !== current));
|
|
45
|
+
},
|
|
46
|
+
acceptsStorageEvent(eventKey) {
|
|
47
|
+
return eventKey === null || eventKey.startsWith(namespace + ":");
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Capture once at entry, before loading history. A mark made during this visit
|
|
53
|
+
// (including one from another tab) can never match this captured revision.
|
|
54
|
+
export function canClearUnreadBookmark({ enteredRevision, current, revealed, atBottom, visible, focused }) {
|
|
55
|
+
return Boolean(enteredRevision && current?.revision === enteredRevision
|
|
56
|
+
&& revealed && atBottom && visible && focused);
|
|
57
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createRoomUnreadStore, canClearUnreadBookmark } from "./room-unread.mjs";
|
|
4
|
+
|
|
5
|
+
test("bookmarks persist per account and room, without changing existing markers or receipt evidence", () => {
|
|
6
|
+
const data = new Map([["legacy-markers", '{"a":"msg_9"}'], ["receipts", '{"a":[1,9]}']]);
|
|
7
|
+
const storage = () => ({ getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value) });
|
|
8
|
+
const store = createRoomUnreadStore({ storage, namespace: "test" });
|
|
9
|
+
const a = store.mark("alice", " RoomA ", "msg_3");
|
|
10
|
+
store.mark("bob", "rooma", "msg_5");
|
|
11
|
+
store.mark("alice", "roomb", "msg_7");
|
|
12
|
+
const reload = createRoomUnreadStore({ storage, namespace: "test" });
|
|
13
|
+
assert.equal(reload.get("alice", "rooma").revision, a.revision);
|
|
14
|
+
assert.equal(reload.get("bob", "rooma").messageId, "msg_5");
|
|
15
|
+
assert.equal(reload.get("alice", "roomb").messageId, "msg_7");
|
|
16
|
+
assert.equal(reload.get("other", "rooma"), null);
|
|
17
|
+
assert.equal(data.get("legacy-markers"), '{"a":"msg_9"}');
|
|
18
|
+
assert.equal(data.get("receipts"), '{"a":[1,9]}');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a stale clear re-reads storage and cannot erase a newer mark from another window", () => {
|
|
22
|
+
const data = new Map();
|
|
23
|
+
const storage = () => ({ getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value) });
|
|
24
|
+
const first = createRoomUnreadStore({ storage, namespace: "test" });
|
|
25
|
+
const second = createRoomUnreadStore({ storage, namespace: "test" });
|
|
26
|
+
const old = first.mark("a", "room", "msg_1");
|
|
27
|
+
const newer = second.mark("a", "room", "msg_2");
|
|
28
|
+
assert.equal(first.clear("a", "room", old.revision), false);
|
|
29
|
+
assert.deepEqual(first.get("a", "room"), newer);
|
|
30
|
+
assert.equal(second.clear("a", "room", newer.revision), true);
|
|
31
|
+
assert.equal(first.get("a", "room"), null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("marking works when randomUUID is absent or unavailable outside a secure context", (t) => {
|
|
35
|
+
const data = new Map();
|
|
36
|
+
const store = createRoomUnreadStore({
|
|
37
|
+
namespace: "test",
|
|
38
|
+
storage: () => ({ getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value) }),
|
|
39
|
+
});
|
|
40
|
+
const original = Object.getOwnPropertyDescriptor(globalThis, "crypto");
|
|
41
|
+
t.after(() => Object.defineProperty(globalThis, "crypto", original));
|
|
42
|
+
for (const crypto of [undefined, {}, { randomUUID() { throw Error("unavailable"); } }]) {
|
|
43
|
+
Object.defineProperty(globalThis, "crypto", { configurable: true, value: crypto });
|
|
44
|
+
const first = store.mark("alice", "room", "msg_1");
|
|
45
|
+
const second = store.mark("alice", "room", "msg_2");
|
|
46
|
+
assert.ok(first?.revision);
|
|
47
|
+
assert.ok(second?.revision);
|
|
48
|
+
assert.notEqual(second.revision, first.revision);
|
|
49
|
+
assert.equal(store.clear("alice", "room", first.revision), false);
|
|
50
|
+
assert.deepEqual(store.get("alice", "room"), second);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("storage getter, read, write and malformed data failures do not escape or create a bookmark", () => {
|
|
55
|
+
const fail = () => { throw new Error("blocked"); };
|
|
56
|
+
for (const storage of [fail, () => ({ getItem: fail, setItem: fail }), () => ({ getItem: () => null, setItem: fail })]) {
|
|
57
|
+
const store = createRoomUnreadStore({ storage, namespace: "test" });
|
|
58
|
+
assert.equal(store.get("a", "room"), null);
|
|
59
|
+
assert.equal(store.mark("a", "room", "msg_1"), null);
|
|
60
|
+
assert.equal(store.clear("a", "room", "r"), false);
|
|
61
|
+
}
|
|
62
|
+
for (const value of ["bad JSON", "null", "{}", '[null,{},{"room":"x"}]']) {
|
|
63
|
+
const store = createRoomUnreadStore({ storage: () => ({ getItem: () => value, setItem() {} }), namespace: "test" });
|
|
64
|
+
assert.equal(store.get("a", "x"), null);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("500 most recently marked rooms are retained; replacing one does not consume another slot", () => {
|
|
69
|
+
let raw = null;
|
|
70
|
+
const store = createRoomUnreadStore({ namespace: "test", storage: () => ({ getItem: () => raw, setItem: (_, value) => { raw = value; } }) });
|
|
71
|
+
for (let i = 0; i < 501; i++) store.mark("a", "room" + i, "msg_" + i);
|
|
72
|
+
assert.equal(store.get("a", "room0"), null);
|
|
73
|
+
assert.equal(store.get("a", "room1").messageId, "msg_1");
|
|
74
|
+
store.mark("a", "room1", "msg_900");
|
|
75
|
+
store.mark("a", "room501", "msg_501");
|
|
76
|
+
assert.equal(store.get("a", "room2"), null);
|
|
77
|
+
assert.equal(store.get("a", "room1").messageId, "msg_900");
|
|
78
|
+
assert.equal(JSON.parse(raw).length, 500);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("only a revealed bookmark from entry can clear at the visible, focused bottom", () => {
|
|
82
|
+
const current = { revision: "old" };
|
|
83
|
+
const eligible = { enteredRevision: "old", current, revealed: true, atBottom: true, visible: true, focused: true };
|
|
84
|
+
assert.equal(canClearUnreadBookmark(eligible), true);
|
|
85
|
+
for (const key of ["revealed", "atBottom", "visible", "focused"]) {
|
|
86
|
+
assert.equal(canClearUnreadBookmark({ ...eligible, [key]: false }), false, key);
|
|
87
|
+
}
|
|
88
|
+
assert.equal(canClearUnreadBookmark({ ...eligible, enteredRevision: null }), false);
|
|
89
|
+
assert.equal(canClearUnreadBookmark({ ...eligible, current: { revision: "new" } }), false);
|
|
90
|
+
assert.equal(canClearUnreadBookmark({ ...eligible, current: null }), false);
|
|
91
|
+
});
|
|
@@ -271,7 +271,7 @@
|
|
|
271
271
|
</template></svg
|
|
272
272
|
></span>
|
|
273
273
|
<span class="rooms-entry-copy"
|
|
274
|
-
><span class="rooms-entry-title">{{ room.title }}
|
|
274
|
+
><span class="rooms-entry-title">{{ room.title }}<span v-if="room.hasUnread" class="rooms-unread-dot" aria-label="Unread messages"> ●</span></span
|
|
275
275
|
><span class="rooms-entry-description"
|
|
276
276
|
><span class="rooms-mobile-kind"
|
|
277
277
|
>{{ roomKindLabel(room) }} · </span
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type SlashPlatform = 'desktop' | 'web';
|
|
2
|
+
export interface SlashCommand {
|
|
3
|
+
readonly name: 'task' | 'search' | 'agent';
|
|
4
|
+
readonly usage: string;
|
|
5
|
+
readonly description: string;
|
|
6
|
+
readonly argument: 'required' | 'none';
|
|
7
|
+
readonly platforms: readonly SlashPlatform[];
|
|
8
|
+
}
|
|
9
|
+
export interface SlashInputContext {
|
|
10
|
+
text: string;
|
|
11
|
+
platform: SlashPlatform;
|
|
12
|
+
hasAttachments?: boolean;
|
|
13
|
+
isReply?: boolean;
|
|
14
|
+
menuDismissed?: boolean;
|
|
15
|
+
selectedCommandIndex?: number;
|
|
16
|
+
}
|
|
17
|
+
export type SlashResolution =
|
|
18
|
+
| { kind: 'none' }
|
|
19
|
+
| { kind: 'menu'; commands: readonly SlashCommand[] }
|
|
20
|
+
| { kind: 'invalid'; command: SlashCommand; hint: string }
|
|
21
|
+
| { kind: 'execute'; command: SlashCommand; argument: string };
|
|
22
|
+
export type ComposerSubmitDecision =
|
|
23
|
+
| { action: 'send_message' }
|
|
24
|
+
| { action: 'complete_command'; text: string }
|
|
25
|
+
| { action: 'show_hint'; hint: string }
|
|
26
|
+
| { action: 'execute_command'; command: SlashCommand; argument: string };
|
|
27
|
+
export declare const SLASH_COMMANDS: readonly SlashCommand[];
|
|
28
|
+
export declare function getSlashCommands(platform: SlashPlatform): readonly SlashCommand[];
|
|
29
|
+
export declare function resolveSlashInput(context: SlashInputContext): SlashResolution;
|
|
30
|
+
export declare function completeSlashCommand(command: SlashCommand): string;
|
|
31
|
+
export declare function decideComposerSubmitAction(context: SlashInputContext): ComposerSubmitDecision;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Local composer commands. Unknown slash-prefixed text remains an ordinary message.
|
|
2
|
+
export const SLASH_COMMANDS = Object.freeze([
|
|
3
|
+
Object.freeze({ name: 'task', usage: '/task <title>', description: 'Create a task on the board', argument: 'required', platforms: ['desktop', 'web'] }),
|
|
4
|
+
Object.freeze({ name: 'search', usage: '/search <query>', description: 'Search messages in this room', argument: 'required', platforms: ['desktop', 'web'] }),
|
|
5
|
+
Object.freeze({ name: 'agent', usage: '/agent', description: 'Add an agent to this room', argument: 'none', platforms: ['desktop'] }),
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export function getSlashCommands(platform) {
|
|
9
|
+
return SLASH_COMMANDS.filter(command => command.platforms.includes(platform));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveSlashInput({ text, platform, hasAttachments, isReply, menuDismissed }) {
|
|
13
|
+
if (!text.startsWith('/') || /[\r\n]/.test(text) || hasAttachments || isReply || menuDismissed) {
|
|
14
|
+
return { kind: 'none' };
|
|
15
|
+
}
|
|
16
|
+
const match = /^\/([^\s]*)(?:\s+(.*))?$/.exec(text);
|
|
17
|
+
if (!match) return { kind: 'none' };
|
|
18
|
+
const name = match[1].toLowerCase();
|
|
19
|
+
const commands = getSlashCommands(platform);
|
|
20
|
+
const command = commands.find(candidate => candidate.name === name);
|
|
21
|
+
if (!command) {
|
|
22
|
+
const matches = match[2] === undefined ? commands.filter(candidate => candidate.name.startsWith(name)) : [];
|
|
23
|
+
return matches.length ? { kind: 'menu', commands: matches } : { kind: 'none' };
|
|
24
|
+
}
|
|
25
|
+
const argument = (match[2] ?? '').trim();
|
|
26
|
+
if (command.argument === 'required' && !argument) {
|
|
27
|
+
return { kind: 'invalid', command, hint: command.name === 'task' ? 'Add a task title' : 'Add a search query' };
|
|
28
|
+
}
|
|
29
|
+
if (command.argument === 'none' && argument) {
|
|
30
|
+
return { kind: 'invalid', command, hint: '/agent does not take arguments' };
|
|
31
|
+
}
|
|
32
|
+
return { kind: 'execute', command, argument };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function completeSlashCommand(command) {
|
|
36
|
+
return `/${command.name}${command.argument === 'required' ? ' ' : ''}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function decideComposerSubmitAction(input) {
|
|
40
|
+
const resolution = resolveSlashInput(input);
|
|
41
|
+
if (resolution.kind === 'none') return { action: 'send_message' };
|
|
42
|
+
if (resolution.kind === 'menu') {
|
|
43
|
+
const command = resolution.commands[input.selectedCommandIndex ?? 0] ?? resolution.commands[0];
|
|
44
|
+
return { action: 'complete_command', text: completeSlashCommand(command) };
|
|
45
|
+
}
|
|
46
|
+
if (resolution.kind === 'invalid') return { action: 'show_hint', hint: resolution.hint };
|
|
47
|
+
return { action: 'execute_command', command: resolution.command, argument: resolution.argument };
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { decideComposerSubmitAction, getSlashCommands, resolveSlashInput } from './slash-commands.mjs';
|
|
4
|
+
|
|
5
|
+
for (const platform of ['desktop', 'web']) {
|
|
6
|
+
test(`${platform}: registry and parser honor the composer command boundary`, () => {
|
|
7
|
+
assert.deepEqual(getSlashCommands(platform).map(command => command.name), platform === 'desktop' ? ['task', 'search', 'agent'] : ['task', 'search']);
|
|
8
|
+
assert.deepEqual(resolveSlashInput({ text: '/', platform }).commands.map(command => command.name), getSlashCommands(platform).map(command => command.name));
|
|
9
|
+
for (const [text, action] of [
|
|
10
|
+
['/t', 'complete_command'], ['/se', 'complete_command'],
|
|
11
|
+
['/task', 'show_hint'], ['/task \t ', 'show_hint'], ['/search ', 'show_hint'],
|
|
12
|
+
['/task Build it', 'execute_command'], ['/search error 404', 'execute_command'],
|
|
13
|
+
['/TASK\tTitle', 'execute_command'],
|
|
14
|
+
['/agent', platform === 'desktop' ? 'execute_command' : 'send_message'],
|
|
15
|
+
['/agent extra', platform === 'desktop' ? 'show_hint' : 'send_message'],
|
|
16
|
+
['/usr/bin', 'send_message'], ['/tasker title', 'send_message'], ['/t title', 'send_message'],
|
|
17
|
+
[' /task title', 'send_message'], ['Hello /task title', 'send_message'],
|
|
18
|
+
['/task first\nsecond', 'send_message'], ['/task title\r', 'send_message'],
|
|
19
|
+
]) assert.equal(decideComposerSubmitAction({ text, platform }).action, action, text);
|
|
20
|
+
for (const flag of ['hasAttachments', 'isReply', 'menuDismissed']) {
|
|
21
|
+
assert.equal(decideComposerSubmitAction({ text: '/task Title', platform, [flag]: true }).action, 'send_message', flag);
|
|
22
|
+
}
|
|
23
|
+
assert.equal(decideComposerSubmitAction({ text: '/', platform, selectedCommandIndex: 1 }).text, '/search ');
|
|
24
|
+
assert.equal(decideComposerSubmitAction({ text: '/task Keep spacing ', platform }).argument, 'Keep spacing');
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="reactions.length" class="message-reactions" role="group" aria-label="Reactions">
|
|
3
|
+
<component
|
|
4
|
+
:is="canReact ? 'button' : 'span'"
|
|
5
|
+
v-for="reaction in reactions"
|
|
6
|
+
:key="reaction.emoji"
|
|
7
|
+
class="message-reaction"
|
|
8
|
+
:type="canReact ? 'button' : undefined"
|
|
9
|
+
:role="canReact ? undefined : 'img'"
|
|
10
|
+
:aria-pressed="canReact ? viewerReacted(reaction.emoji) : undefined"
|
|
11
|
+
:aria-label="label(reaction)"
|
|
12
|
+
:title="label(reaction)"
|
|
13
|
+
@click="canReact && emit('toggle', reaction.emoji)"
|
|
14
|
+
>
|
|
15
|
+
<span class="message-reaction-emoji" aria-hidden="true">{{ reaction.emoji }}</span>
|
|
16
|
+
<span class="message-reaction-count" aria-hidden="true">{{ reaction.count }}</span>
|
|
17
|
+
</component>
|
|
18
|
+
<button
|
|
19
|
+
v-if="canReact"
|
|
20
|
+
class="message-reaction message-reaction-add"
|
|
21
|
+
type="button"
|
|
22
|
+
aria-label="Add reaction"
|
|
23
|
+
title="Add reaction"
|
|
24
|
+
aria-haspopup="dialog"
|
|
25
|
+
:aria-expanded="pickerOpen ?? false"
|
|
26
|
+
@click="emit('add', $event)"
|
|
27
|
+
>
|
|
28
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
29
|
+
<path d="M22 11v1a10 10 0 1 1-9-10" />
|
|
30
|
+
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
|
31
|
+
<path d="M9 9h.01M15 9h.01" />
|
|
32
|
+
<path d="M16 5h6M19 2v6" />
|
|
33
|
+
</svg>
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
import { describeMessageReaction, type MessageReaction } from "../message-reactions.mjs";
|
|
40
|
+
|
|
41
|
+
const props = defineProps<{
|
|
42
|
+
reactions: readonly MessageReaction[];
|
|
43
|
+
viewerLogin: string | null;
|
|
44
|
+
/** Whether the viewer reacted with this emoji, including when they are past the listed reactors. */
|
|
45
|
+
viewerReacted: (emoji: string) => boolean;
|
|
46
|
+
canReact: boolean;
|
|
47
|
+
pickerOpen?: boolean;
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
toggle: [emoji: string];
|
|
52
|
+
/** The click that asked for the picker; its target is the anchor. */
|
|
53
|
+
add: [event: MouseEvent];
|
|
54
|
+
}>();
|
|
55
|
+
|
|
56
|
+
function label(reaction: MessageReaction): string {
|
|
57
|
+
const description = describeMessageReaction(reaction, props.viewerLogin);
|
|
58
|
+
// The list may not name the viewer when many people reacted.
|
|
59
|
+
return props.viewerReacted(reaction.emoji) && !description.startsWith("You")
|
|
60
|
+
? `You and others reacted with ${reaction.emoji}`
|
|
61
|
+
: description;
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<style src="./message-reactions.css"></style>
|