letagents 0.12.34 → 0.12.36
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/dist/mcp/server/runtime/workspace-capture.js +16 -5
- package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
- package/package.json +5 -3
- package/shared/activation-routing.d.mts +21 -3
- package/shared/activation-routing.mjs +37 -0
- 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 +5 -3
- package/shared/codex-read-only-room-tools.d.mts +2 -0
- package/shared/codex-read-only-room-tools.mjs +24 -0
- package/shared/mcp-workspace-capture-worker.mjs +2 -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 +17 -0
- package/shared/message-link-previews.mjs +76 -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-settings.d.mts +16 -0
- package/shared/room-settings.mjs +14 -0
- package/shared/room-typing.d.mts +18 -0
- package/shared/room-typing.mjs +129 -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/GitHubEventCard.vue +229 -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 +129 -0
- package/shared/ui/TypingIndicator.vue +134 -0
- package/shared/ui/message-reactions.css +254 -0
- package/shared/ui/message-search.css +186 -0
- package/shared/ui/room-agent-work.css +178 -0
- package/shared/ui/room-message-motion.ts +143 -0
- package/shared/ui/useComposerSlashCommands.ts +92 -0
- package/shared/ui/usePinnedMessageVisibility.ts +20 -0
- package/shared/ui/useRoomMessageMotion.ts +195 -0
- package/shared/ui/useSlidingTabIndicator.ts +72 -0
- package/shared/workspace-change-capture.mjs +3 -1
- package/shared/workspace-turn-capture.d.mts +4 -1
- package/shared/workspace-turn-capture.mjs +203 -6
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { MessageSearchQueryError, MessageSearchResponse } from "./message-search.mjs";
|
|
2
|
+
|
|
3
|
+
export const MESSAGE_SEARCH_DEBOUNCE_MS: 250;
|
|
4
|
+
|
|
5
|
+
export interface MessageSearchState<Hit extends { id: string }> {
|
|
6
|
+
/**
|
|
7
|
+
* `idle`: nothing to search yet. `invalid`: the query breaks a limit (see `error`).
|
|
8
|
+
* `loading`: the first page is on its way. `ready` / `error`: it arrived, or failed.
|
|
9
|
+
*/
|
|
10
|
+
status: "idle" | "invalid" | "loading" | "ready" | "error";
|
|
11
|
+
terms: readonly string[];
|
|
12
|
+
/** Newest first. */
|
|
13
|
+
hits: readonly Hit[];
|
|
14
|
+
hasMore: boolean;
|
|
15
|
+
loadingMore: boolean;
|
|
16
|
+
/** A query limit name when `invalid`; otherwise the last request's failure, if any. */
|
|
17
|
+
error: MessageSearchQueryError | string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MessageSearchController<Hit extends { id: string }> {
|
|
21
|
+
readonly state: MessageSearchState<Hit>;
|
|
22
|
+
/** Search for a new query after a short pause in typing. */
|
|
23
|
+
setQuery(value: string): void;
|
|
24
|
+
/** Read the next, older page of the current results. */
|
|
25
|
+
loadMore(): Promise<void>;
|
|
26
|
+
/** Stop searching; call when the search closes or the room changes. */
|
|
27
|
+
reset(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The client-side state of one room's history search, shared by the desktop
|
|
32
|
+
* and web apps. Answers that arrive for an earlier query are dropped.
|
|
33
|
+
*/
|
|
34
|
+
export function createMessageSearchController<Hit extends { id: string }>(options: {
|
|
35
|
+
search(query: string, before: string | null): Promise<Pick<MessageSearchResponse<Hit>, "messages" | "has_more" | "next_before"> & { terms?: string[] }>;
|
|
36
|
+
onChange?(): void;
|
|
37
|
+
debounceMs?: number;
|
|
38
|
+
}): MessageSearchController<Hit>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The client-side state of one room's history search, shared by the desktop
|
|
2
|
+
// and web apps. Framework-free: the app supplies the request and is told when
|
|
3
|
+
// something changed.
|
|
4
|
+
import { parseMessageSearchQuery } from "./message-search.mjs";
|
|
5
|
+
|
|
6
|
+
export const MESSAGE_SEARCH_DEBOUNCE_MS = 250;
|
|
7
|
+
|
|
8
|
+
const IDLE = Object.freeze({ status: "idle", terms: [], hits: [], hasMore: false, loadingMore: false, error: null });
|
|
9
|
+
|
|
10
|
+
export function createMessageSearchController({ search, onChange, debounceMs = MESSAGE_SEARCH_DEBOUNCE_MS }) {
|
|
11
|
+
let state = IDLE;
|
|
12
|
+
let query = "";
|
|
13
|
+
let nextBefore = null;
|
|
14
|
+
// Every new query (and reset) starts an epoch; answers from an earlier one are dropped.
|
|
15
|
+
let epoch = 0;
|
|
16
|
+
let timer = null;
|
|
17
|
+
|
|
18
|
+
function set(next) {
|
|
19
|
+
state = next;
|
|
20
|
+
onChange?.();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cancelTimer() {
|
|
24
|
+
if (timer !== null) clearTimeout(timer);
|
|
25
|
+
timer = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function run(terms, startedIn) {
|
|
29
|
+
try {
|
|
30
|
+
const page = await search(query, null);
|
|
31
|
+
if (startedIn !== epoch) return;
|
|
32
|
+
nextBefore = page?.next_before ?? null;
|
|
33
|
+
set({
|
|
34
|
+
status: "ready",
|
|
35
|
+
terms: Array.isArray(page?.terms) ? page.terms : terms,
|
|
36
|
+
hits: Array.isArray(page?.messages) ? page.messages : [],
|
|
37
|
+
hasMore: Boolean(page?.has_more && nextBefore),
|
|
38
|
+
loadingMore: false,
|
|
39
|
+
error: null,
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (startedIn !== epoch) return;
|
|
43
|
+
set({ ...IDLE, status: "error", terms, error: error instanceof Error && error.message ? error.message : "Search failed." });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Search for a new query after a short pause in typing. */
|
|
48
|
+
function setQuery(value) {
|
|
49
|
+
const next = typeof value === "string" ? value : "";
|
|
50
|
+
if (next.trim() === query.trim() && state !== IDLE) return;
|
|
51
|
+
query = next;
|
|
52
|
+
epoch += 1;
|
|
53
|
+
cancelTimer();
|
|
54
|
+
nextBefore = null;
|
|
55
|
+
const parsed = parseMessageSearchQuery(query);
|
|
56
|
+
if (parsed.error) {
|
|
57
|
+
// Too short is simply "not searching yet"; the other limits are worth saying.
|
|
58
|
+
set(parsed.error === "too_short" ? IDLE : { ...IDLE, status: "invalid", error: parsed.error });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const startedIn = epoch;
|
|
62
|
+
set({ ...IDLE, status: "loading", terms: parsed.terms });
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
timer = null;
|
|
65
|
+
void run(parsed.terms, startedIn);
|
|
66
|
+
}, debounceMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read the next, older page of the current results. */
|
|
70
|
+
async function loadMore() {
|
|
71
|
+
if (state.status !== "ready" || !state.hasMore || state.loadingMore || !nextBefore) return;
|
|
72
|
+
const startedIn = epoch;
|
|
73
|
+
const before = nextBefore;
|
|
74
|
+
set({ ...state, loadingMore: true, error: null });
|
|
75
|
+
try {
|
|
76
|
+
const page = await search(query, before);
|
|
77
|
+
if (startedIn !== epoch) return;
|
|
78
|
+
nextBefore = page?.next_before ?? null;
|
|
79
|
+
const known = new Set(state.hits.map((hit) => hit.id));
|
|
80
|
+
const more = (Array.isArray(page?.messages) ? page.messages : []).filter((hit) => !known.has(hit.id));
|
|
81
|
+
set({ ...state, hits: [...state.hits, ...more], hasMore: Boolean(page?.has_more && nextBefore), loadingMore: false });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (startedIn !== epoch) return;
|
|
84
|
+
// Keep what is shown; the person can press "more" again.
|
|
85
|
+
set({ ...state, loadingMore: false, error: error instanceof Error && error.message ? error.message : "Search failed." });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Stop searching; call when the search closes or the room changes. */
|
|
90
|
+
function reset() {
|
|
91
|
+
query = "";
|
|
92
|
+
epoch += 1;
|
|
93
|
+
cancelTimer();
|
|
94
|
+
nextBefore = null;
|
|
95
|
+
if (state !== IDLE) set(IDLE);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { get state() { return state; }, setQuery, loadMore, reset };
|
|
99
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const MESSAGE_SEARCH_MIN_QUERY_CHARS: 2;
|
|
2
|
+
export const MESSAGE_SEARCH_MAX_QUERY_CHARS: 200;
|
|
3
|
+
export const MESSAGE_SEARCH_MAX_TERMS: 6;
|
|
4
|
+
export const MESSAGE_SEARCH_DEFAULT_LIMIT: 30;
|
|
5
|
+
export const MESSAGE_SEARCH_MAX_LIMIT: 50;
|
|
6
|
+
|
|
7
|
+
export type MessageSearchQueryError = "too_short" | "too_long" | "too_many_terms";
|
|
8
|
+
|
|
9
|
+
export type ParsedMessageSearchQuery =
|
|
10
|
+
| { terms: string[]; error?: undefined }
|
|
11
|
+
| { error: MessageSearchQueryError; terms?: undefined };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split a query into its terms, or say why it cannot be searched. A term is a
|
|
15
|
+
* word, or a phrase in double quotes; a message matches when it contains every
|
|
16
|
+
* term as a substring, ignoring case.
|
|
17
|
+
*/
|
|
18
|
+
export function parseMessageSearchQuery(value: unknown): ParsedMessageSearchQuery;
|
|
19
|
+
|
|
20
|
+
/** Whether a text contains every term, the same test the server applies. */
|
|
21
|
+
export function textMatchesMessageSearch(text: unknown, terms: readonly string[]): boolean;
|
|
22
|
+
|
|
23
|
+
/** `GET /rooms/:room/messages/search` */
|
|
24
|
+
export interface MessageSearchResponse<Message = unknown> {
|
|
25
|
+
room_id: string;
|
|
26
|
+
terms: string[];
|
|
27
|
+
/** Newest first. */
|
|
28
|
+
messages: Message[];
|
|
29
|
+
has_more: boolean;
|
|
30
|
+
/** Pass as `before` to read the next, older page; null on the last page. */
|
|
31
|
+
next_before: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MessageSearchSegment {
|
|
35
|
+
text: string;
|
|
36
|
+
match: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split a text into runs that do and do not match the terms, for rendering
|
|
41
|
+
* highlights as plain text nodes (never as HTML).
|
|
42
|
+
*/
|
|
43
|
+
export function highlightMessageSearchText(text: unknown, terms: readonly string[]): MessageSearchSegment[];
|
|
44
|
+
|
|
45
|
+
/** A short excerpt around the first match, with an ellipsis where text was left out. */
|
|
46
|
+
export function messageSearchSnippet(text: unknown, terms: readonly string[], maxLength?: number): string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Searching a room's messages: the limits and the one query grammar that the
|
|
2
|
+
// server and both apps share. A query is a list of terms; a message matches
|
|
3
|
+
// when it contains every term, ignoring case. A term is a word, or a phrase
|
|
4
|
+
// in double quotes. Matching is by substring, so part of an identifier, a
|
|
5
|
+
// file path or an error string finds the message.
|
|
6
|
+
|
|
7
|
+
export const MESSAGE_SEARCH_MIN_QUERY_CHARS = 2;
|
|
8
|
+
export const MESSAGE_SEARCH_MAX_QUERY_CHARS = 200;
|
|
9
|
+
export const MESSAGE_SEARCH_MAX_TERMS = 6;
|
|
10
|
+
export const MESSAGE_SEARCH_DEFAULT_LIMIT = 30;
|
|
11
|
+
export const MESSAGE_SEARCH_MAX_LIMIT = 50;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split a query into its terms, or say why it cannot be searched.
|
|
15
|
+
* Returns `{ terms }` or `{ error: "too_short" | "too_long" | "too_many_terms" }`.
|
|
16
|
+
*/
|
|
17
|
+
export function parseMessageSearchQuery(value) {
|
|
18
|
+
const query = typeof value === "string" ? value.trim() : "";
|
|
19
|
+
if (query.length > MESSAGE_SEARCH_MAX_QUERY_CHARS) return { error: "too_long" };
|
|
20
|
+
|
|
21
|
+
const terms = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
// A phrase runs to its closing quote, or to the end when the quote is never closed.
|
|
24
|
+
for (const match of query.matchAll(/"([^"]*)"?|(\S+)/g)) {
|
|
25
|
+
const term = (match[1] ?? match[2] ?? "").replace(/\s+/g, " ").trim();
|
|
26
|
+
const key = term.toLowerCase();
|
|
27
|
+
if (!term || seen.has(key)) continue;
|
|
28
|
+
seen.add(key);
|
|
29
|
+
terms.push(term);
|
|
30
|
+
}
|
|
31
|
+
if (terms.join("").length < MESSAGE_SEARCH_MIN_QUERY_CHARS) return { error: "too_short" };
|
|
32
|
+
if (terms.length > MESSAGE_SEARCH_MAX_TERMS) return { error: "too_many_terms" };
|
|
33
|
+
return { terms };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether a text contains every term, the same test the server applies. */
|
|
37
|
+
export function textMatchesMessageSearch(text, terms) {
|
|
38
|
+
const haystack = String(text ?? "").toLowerCase();
|
|
39
|
+
return terms.length > 0 && terms.every((term) => haystack.includes(term.toLowerCase()));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function matchRanges(text, terms) {
|
|
43
|
+
const lower = text.toLowerCase();
|
|
44
|
+
// Lowercasing can expand a character (İ -> i + combining dot). Keep the
|
|
45
|
+
// original UTF-16 boundaries so highlights and snippets still slice source text.
|
|
46
|
+
let sourceOffsets = null;
|
|
47
|
+
if (lower.length !== text.length) {
|
|
48
|
+
sourceOffsets = [];
|
|
49
|
+
let start = 0;
|
|
50
|
+
for (const character of text) {
|
|
51
|
+
const end = start + character.length;
|
|
52
|
+
for (let index = 0; index < character.toLowerCase().length; index += 1) {
|
|
53
|
+
sourceOffsets.push([start, end]);
|
|
54
|
+
}
|
|
55
|
+
start = end;
|
|
56
|
+
}
|
|
57
|
+
// Context-sensitive casing must not leave offsets outside the map.
|
|
58
|
+
if (sourceOffsets.length !== lower.length) return [];
|
|
59
|
+
}
|
|
60
|
+
const ranges = [];
|
|
61
|
+
for (const term of terms) {
|
|
62
|
+
const needle = term.toLowerCase();
|
|
63
|
+
if (!needle) continue;
|
|
64
|
+
for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, at + 1)) {
|
|
65
|
+
ranges.push(sourceOffsets
|
|
66
|
+
? [sourceOffsets[at][0], sourceOffsets[at + needle.length - 1][1]]
|
|
67
|
+
: [at, at + needle.length]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
ranges.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
|
|
71
|
+
// Overlapping and touching matches read as one highlighted run.
|
|
72
|
+
const merged = [];
|
|
73
|
+
for (const range of ranges) {
|
|
74
|
+
const last = merged.at(-1);
|
|
75
|
+
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
|
|
76
|
+
else merged.push([...range]);
|
|
77
|
+
}
|
|
78
|
+
return merged;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Split a text into runs that do and do not match the terms, for rendering
|
|
83
|
+
* highlights as plain text nodes (never as HTML).
|
|
84
|
+
*/
|
|
85
|
+
export function highlightMessageSearchText(text, terms) {
|
|
86
|
+
const source = String(text ?? "");
|
|
87
|
+
const segments = [];
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
for (const [start, end] of matchRanges(source, terms)) {
|
|
90
|
+
if (start > cursor) segments.push({ text: source.slice(cursor, start), match: false });
|
|
91
|
+
segments.push({ text: source.slice(start, end), match: true });
|
|
92
|
+
cursor = end;
|
|
93
|
+
}
|
|
94
|
+
if (cursor < source.length) segments.push({ text: source.slice(cursor), match: false });
|
|
95
|
+
return segments;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A short excerpt around the first match: whitespace collapsed, cut at
|
|
100
|
+
* `maxLength` characters, with an ellipsis where text was left out.
|
|
101
|
+
*/
|
|
102
|
+
export function messageSearchSnippet(text, terms, maxLength = 220) {
|
|
103
|
+
const source = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
104
|
+
if (source.length <= maxLength) return source;
|
|
105
|
+
const first = matchRanges(source, terms)[0]?.[0] ?? 0;
|
|
106
|
+
// Keep a little lead-in so the match is read in its sentence.
|
|
107
|
+
let start = Math.max(0, Math.min(first - Math.floor(maxLength / 4), source.length - maxLength));
|
|
108
|
+
if (start > 0) {
|
|
109
|
+
const space = source.indexOf(" ", start);
|
|
110
|
+
if (space !== -1 && space < first) start = space + 1;
|
|
111
|
+
}
|
|
112
|
+
const end = Math.min(source.length, start + maxLength);
|
|
113
|
+
return `${start > 0 ? "…" : ""}${source.slice(start, end).trim()}${end < source.length ? "…" : ""}`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const LETAGENTS_ROOM_ORIGIN: string;
|
|
2
|
+
export const MESSAGE_ID_PATTERN: RegExp;
|
|
3
|
+
|
|
4
|
+
export function isValidMessageId(messageId: unknown): messageId is string;
|
|
5
|
+
export function encodeRoomPathIdentifier(identifier: string): string;
|
|
6
|
+
export function decodeRoomPath(path: string): string;
|
|
7
|
+
export function isLocalRoomIdentifier(identifier: string | null | undefined): boolean;
|
|
8
|
+
|
|
9
|
+
export function buildLetAgentsMessageUrl(
|
|
10
|
+
roomIdentifier: string,
|
|
11
|
+
messageId: string,
|
|
12
|
+
origin?: string,
|
|
13
|
+
): string;
|
|
14
|
+
|
|
15
|
+
export function parseLetAgentsMessageUrl(
|
|
16
|
+
rawUrl: string,
|
|
17
|
+
configuredOrigin?: string,
|
|
18
|
+
): { roomIdentifier: string; messageId: string | null } | null;
|
|
19
|
+
|
|
20
|
+
export function resolveSameRoomMessageReference(
|
|
21
|
+
rawUrl: string | null | undefined,
|
|
22
|
+
currentRoomIdentifier: string | null | undefined,
|
|
23
|
+
configuredOrigin?: string,
|
|
24
|
+
): string | null;
|
|
25
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export const LETAGENTS_ROOM_ORIGIN = "https://letagents.chat";
|
|
2
|
+
|
|
3
|
+
export const MESSAGE_ID_PATTERN = /^msg_[1-9]\d*$/;
|
|
4
|
+
|
|
5
|
+
export function isValidMessageId(messageId) {
|
|
6
|
+
return typeof messageId === "string" && MESSAGE_ID_PATTERN.test(messageId);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function encodeRoomPathIdentifier(identifier) {
|
|
10
|
+
return String(identifier ?? "")
|
|
11
|
+
.split("/")
|
|
12
|
+
.map((segment) => encodeURIComponent(segment))
|
|
13
|
+
.join("/");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function decodeRoomPath(path) {
|
|
17
|
+
return String(path ?? "")
|
|
18
|
+
.split("/")
|
|
19
|
+
.map((segment) => {
|
|
20
|
+
try {
|
|
21
|
+
return decodeURIComponent(segment);
|
|
22
|
+
} catch {
|
|
23
|
+
return segment;
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.join("/");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isLocalRoomIdentifier(identifier) {
|
|
30
|
+
const value = String(identifier ?? "").trim();
|
|
31
|
+
return /^local[_-]/i.test(value) || /^git-room:local:/i.test(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildLetAgentsMessageUrl(
|
|
35
|
+
roomIdentifier,
|
|
36
|
+
messageId,
|
|
37
|
+
origin = LETAGENTS_ROOM_ORIGIN,
|
|
38
|
+
) {
|
|
39
|
+
const cleanId = String(messageId ?? "").trim();
|
|
40
|
+
if (!isValidMessageId(cleanId)) {
|
|
41
|
+
throw new Error(`Invalid message ID for permalink: ${messageId}`);
|
|
42
|
+
}
|
|
43
|
+
const cleanRoom = String(roomIdentifier ?? "").trim();
|
|
44
|
+
if (!cleanRoom) {
|
|
45
|
+
throw new Error("Room identifier is required for permalink.");
|
|
46
|
+
}
|
|
47
|
+
const base = origin ? origin.replace(/\/+$/, "") : "";
|
|
48
|
+
return `${base}/in/${encodeRoomPathIdentifier(cleanRoom)}?message=${cleanId}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseLetAgentsMessageUrl(
|
|
52
|
+
rawUrl,
|
|
53
|
+
configuredOrigin = LETAGENTS_ROOM_ORIGIN,
|
|
54
|
+
) {
|
|
55
|
+
if (typeof rawUrl !== "string") return null;
|
|
56
|
+
const trimmed = rawUrl.trim();
|
|
57
|
+
if (!trimmed) return null;
|
|
58
|
+
|
|
59
|
+
let parsed;
|
|
60
|
+
let isRelative = false;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new URL(trimmed);
|
|
63
|
+
} catch {
|
|
64
|
+
if (trimmed.startsWith("/in/") || trimmed.startsWith("in/")) {
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(trimmed.startsWith("/") ? trimmed : `/${trimmed}`, "http://localhost");
|
|
67
|
+
isRelative = true;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isRelative) {
|
|
77
|
+
let normalizedConfiguredOrigin;
|
|
78
|
+
try {
|
|
79
|
+
normalizedConfiguredOrigin = new URL(configuredOrigin).origin.toLowerCase();
|
|
80
|
+
} catch {
|
|
81
|
+
normalizedConfiguredOrigin = String(configuredOrigin).replace(/\/+$/, "").toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
if (parsed.origin.toLowerCase() !== normalizedConfiguredOrigin) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pathname = parsed.pathname.replace(/^\/+|\/+$/g, "");
|
|
89
|
+
if (!pathname.startsWith("in/")) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const rawRoom = pathname.slice("in/".length);
|
|
93
|
+
if (!rawRoom) return null;
|
|
94
|
+
const roomIdentifier = decodeRoomPath(rawRoom);
|
|
95
|
+
if (!roomIdentifier.trim()) return null;
|
|
96
|
+
|
|
97
|
+
const rawMessageId = parsed.searchParams.get("message");
|
|
98
|
+
const messageId = isValidMessageId(rawMessageId) ? rawMessageId.trim() : null;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
roomIdentifier,
|
|
102
|
+
messageId,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveSameRoomMessageReference(
|
|
107
|
+
rawUrl,
|
|
108
|
+
currentRoomIdentifier,
|
|
109
|
+
configuredOrigin = LETAGENTS_ROOM_ORIGIN,
|
|
110
|
+
) {
|
|
111
|
+
if (typeof rawUrl !== "string" || !currentRoomIdentifier) return null;
|
|
112
|
+
const parsed = parseLetAgentsMessageUrl(rawUrl, configuredOrigin);
|
|
113
|
+
if (!parsed?.messageId) return null;
|
|
114
|
+
if (parsed.roomIdentifier.trim() !== String(currentRoomIdentifier).trim()) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return parsed.messageId;
|
|
118
|
+
}
|
|
119
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RoomNotificationPreference, RoomNotificationPreferenceChange, RoomNotificationPreferenceEntry, RoomNotificationPreferenceList } from './room-notification-preferences.mjs';
|
|
2
|
+
export interface RoomNotificationClientState {
|
|
3
|
+
preference: RoomNotificationPreference; ready: boolean; loading: boolean; busy: boolean; error: string; version: number;
|
|
4
|
+
}
|
|
5
|
+
export declare function createRoomNotificationClient(api: {
|
|
6
|
+
get(roomId: string): Promise<RoomNotificationPreferenceEntry>;
|
|
7
|
+
list?(): Promise<RoomNotificationPreferenceList>;
|
|
8
|
+
put?(roomId: string, change: RoomNotificationPreferenceChange): Promise<RoomNotificationPreferenceEntry>;
|
|
9
|
+
}, changed?: () => void, normalizeKey?: (roomId: string) => string): {
|
|
10
|
+
setViewer(viewer: { id: string; login: string } | null): void;
|
|
11
|
+
signedIn(): boolean;
|
|
12
|
+
allowsAfterRead(roomId: string, text: unknown): Promise<boolean>;
|
|
13
|
+
state(roomId: string): RoomNotificationClientState;
|
|
14
|
+
refresh(roomId: string): Promise<void>;
|
|
15
|
+
refreshAll(): Promise<void>;
|
|
16
|
+
update(roomId: string, change: RoomNotificationPreferenceChange): Promise<void>;
|
|
17
|
+
allows(roomId: string, text: unknown, now?: number): boolean;
|
|
18
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { allowsRoomNotification, DEFAULT_ROOM_NOTIFICATION_PREFERENCE } from './room-notification-preferences.mjs';
|
|
2
|
+
|
|
3
|
+
/** Personal cache shared by desktop and the read-only web sound gate. */
|
|
4
|
+
export function createRoomNotificationClient(api, changed = () => {}, normalizeKey = (id) => id) {
|
|
5
|
+
let viewer = null;
|
|
6
|
+
let epoch = 0;
|
|
7
|
+
let listVersion = 0;
|
|
8
|
+
let listLoading = false;
|
|
9
|
+
const rooms = new Map();
|
|
10
|
+
const pendingReads = new Map();
|
|
11
|
+
let pendingList = null;
|
|
12
|
+
function state(roomId) {
|
|
13
|
+
roomId = normalizeKey(roomId);
|
|
14
|
+
if (!rooms.has(roomId)) rooms.set(roomId, {
|
|
15
|
+
preference: { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE }, ready: false, loading: false,
|
|
16
|
+
busy: false, error: '', version: 0,
|
|
17
|
+
});
|
|
18
|
+
return rooms.get(roomId);
|
|
19
|
+
}
|
|
20
|
+
function setViewer(next) {
|
|
21
|
+
if (viewer?.id === next?.id && viewer?.login === next?.login) return;
|
|
22
|
+
viewer = next;
|
|
23
|
+
epoch++;
|
|
24
|
+
listVersion++;
|
|
25
|
+
listLoading = false;
|
|
26
|
+
rooms.clear();
|
|
27
|
+
pendingReads.clear();
|
|
28
|
+
pendingList = null;
|
|
29
|
+
changed();
|
|
30
|
+
}
|
|
31
|
+
async function performRefresh(roomId) {
|
|
32
|
+
if (!viewer || !roomId) return;
|
|
33
|
+
const row = state(roomId);
|
|
34
|
+
if (row.busy) return;
|
|
35
|
+
const version = ++row.version;
|
|
36
|
+
const currentEpoch = epoch;
|
|
37
|
+
row.loading = true;
|
|
38
|
+
changed();
|
|
39
|
+
try {
|
|
40
|
+
const preference = await api.get(roomId);
|
|
41
|
+
if (epoch !== currentEpoch || row.version !== version) return;
|
|
42
|
+
row.preference = preference;
|
|
43
|
+
row.error = '';
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (epoch !== currentEpoch || row.version !== version) return;
|
|
46
|
+
if (!row.ready) row.preference = { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
|
|
47
|
+
row.error = error instanceof Error ? error.message : 'Notification settings could not be loaded.';
|
|
48
|
+
} finally {
|
|
49
|
+
if (epoch === currentEpoch && row.version === version) {
|
|
50
|
+
row.loading = false;
|
|
51
|
+
row.ready = true;
|
|
52
|
+
changed();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function refresh(roomId) {
|
|
57
|
+
roomId = normalizeKey(roomId);
|
|
58
|
+
const pending = performRefresh(roomId);
|
|
59
|
+
pendingReads.set(roomId, pending);
|
|
60
|
+
void pending.then(() => { if (pendingReads.get(roomId) === pending) pendingReads.delete(roomId); });
|
|
61
|
+
return pending;
|
|
62
|
+
}
|
|
63
|
+
async function performRefreshAll() {
|
|
64
|
+
if (!viewer || !api.list) return;
|
|
65
|
+
const currentEpoch = epoch;
|
|
66
|
+
const version = ++listVersion;
|
|
67
|
+
listLoading = true;
|
|
68
|
+
const roomVersions = new Map([...rooms].map(([id, row]) => [id, row.version]));
|
|
69
|
+
try {
|
|
70
|
+
const result = await api.list();
|
|
71
|
+
if (currentEpoch !== epoch || version !== listVersion) return;
|
|
72
|
+
const entries = new Map(result.preferences.map((entry) => [normalizeKey(entry.room_id), entry]));
|
|
73
|
+
for (const id of new Set([...rooms.keys(), ...entries.keys()])) {
|
|
74
|
+
const row = state(id);
|
|
75
|
+
if (row.version !== (roomVersions.get(id) ?? 0) || row.loading || row.busy) continue;
|
|
76
|
+
if (!entries.has(id) && result.truncated) continue;
|
|
77
|
+
row.preference = entries.get(id) ?? { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
|
|
78
|
+
row.ready = true;
|
|
79
|
+
row.error = '';
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (currentEpoch !== epoch || version !== listVersion) return;
|
|
83
|
+
for (const [id, row] of rooms) {
|
|
84
|
+
if (row.version !== (roomVersions.get(id) ?? 0) || row.loading || row.busy) continue;
|
|
85
|
+
if (!row.ready) row.preference = { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
|
|
86
|
+
row.ready = true;
|
|
87
|
+
row.error = error instanceof Error ? error.message : 'Notification settings could not be loaded.';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (version === listVersion && currentEpoch === epoch) listLoading = false;
|
|
91
|
+
changed();
|
|
92
|
+
}
|
|
93
|
+
function refreshAll() {
|
|
94
|
+
const pending = performRefreshAll();
|
|
95
|
+
pendingList = pending;
|
|
96
|
+
void pending.then(() => { if (pendingList === pending) pendingList = null; });
|
|
97
|
+
return pending;
|
|
98
|
+
}
|
|
99
|
+
async function update(roomId, change) {
|
|
100
|
+
roomId = normalizeKey(roomId);
|
|
101
|
+
if (!viewer || !api.put) throw new Error('Sign in to manage your room notifications.');
|
|
102
|
+
const row = state(roomId);
|
|
103
|
+
if (row.busy) return;
|
|
104
|
+
row.busy = true;
|
|
105
|
+
row.loading = false;
|
|
106
|
+
row.version++;
|
|
107
|
+
listVersion++;
|
|
108
|
+
listLoading = false;
|
|
109
|
+
const currentEpoch = epoch;
|
|
110
|
+
changed();
|
|
111
|
+
try {
|
|
112
|
+
const preference = await api.put(roomId, change);
|
|
113
|
+
if (currentEpoch !== epoch) return;
|
|
114
|
+
row.preference = preference;
|
|
115
|
+
row.ready = true;
|
|
116
|
+
row.error = '';
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (currentEpoch !== epoch) return;
|
|
119
|
+
row.error = error instanceof Error ? error.message : 'Notification settings could not be saved.';
|
|
120
|
+
} finally {
|
|
121
|
+
if (currentEpoch === epoch) { row.busy = false; changed(); }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function allows(roomId, text, now = Date.now()) {
|
|
125
|
+
const row = state(roomId);
|
|
126
|
+
if ((row.loading || listLoading) && !row.ready) return false;
|
|
127
|
+
return allowsRoomNotification(row.preference, text, viewer?.login, now);
|
|
128
|
+
}
|
|
129
|
+
async function allowsAfterRead(roomId, text) {
|
|
130
|
+
roomId = normalizeKey(roomId);
|
|
131
|
+
const currentEpoch = epoch;
|
|
132
|
+
while (currentEpoch === epoch && !state(roomId).ready) {
|
|
133
|
+
const pending = pendingReads.get(roomId) ?? pendingList;
|
|
134
|
+
if (!pending) break;
|
|
135
|
+
await pending;
|
|
136
|
+
}
|
|
137
|
+
return currentEpoch === epoch && allows(roomId, text);
|
|
138
|
+
}
|
|
139
|
+
return { setViewer, state, refresh, refreshAll, update, allows, allowsAfterRead, signedIn: () => Boolean(viewer) };
|
|
140
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type RoomNotificationLevel = 'all' | 'mentions' | 'muted';
|
|
2
|
+
export interface RoomNotificationPreference {
|
|
3
|
+
level: RoomNotificationLevel;
|
|
4
|
+
snoozed_until: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface RoomNotificationPreferenceEntry extends RoomNotificationPreference { room_id: string }
|
|
7
|
+
export interface RoomNotificationPreferenceList { preferences: RoomNotificationPreferenceEntry[]; truncated: boolean }
|
|
8
|
+
export type RoomNotificationPreferenceChange = Partial<RoomNotificationPreference>;
|
|
9
|
+
export type RoomNotificationSnoozePreset = '1h' | '8h' | 'tomorrow';
|
|
10
|
+
export declare const ROOM_NOTIFICATION_LEVELS: readonly RoomNotificationLevel[];
|
|
11
|
+
export declare const DEFAULT_ROOM_NOTIFICATION_PREFERENCE: Readonly<RoomNotificationPreference>;
|
|
12
|
+
export declare const PERSON_MENTION_START: string;
|
|
13
|
+
export declare const PERSON_MENTION_END: string;
|
|
14
|
+
export declare function mentionsPerson(text: unknown, login: string | null | undefined): boolean;
|
|
15
|
+
export declare function roomNotificationsSuppressed(preference: RoomNotificationPreference | null | undefined, now?: number): boolean;
|
|
16
|
+
export declare function allowsRoomNotification(preference: RoomNotificationPreference | null | undefined, text: unknown, login: string | null | undefined, now?: number): boolean;
|
|
17
|
+
export declare function roomNotificationSnoozeUntil(preset: RoomNotificationSnoozePreset, now?: Date): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const ROOM_NOTIFICATION_LEVELS = ['all', 'mentions', 'muted'];
|
|
2
|
+
export const DEFAULT_ROOM_NOTIFICATION_PREFERENCE = Object.freeze({ level: 'all', snoozed_until: null });
|
|
3
|
+
// Shared with PostgreSQL's regex predicate. A final sentence period is punctuation;
|
|
4
|
+
// a dot followed by a handle character is part of a longer handle.
|
|
5
|
+
export const PERSON_MENTION_START = '(^|[^A-Za-z0-9_./@:-])@';
|
|
6
|
+
export const PERSON_MENTION_END = '(?![A-Za-z0-9_/@:-]|\\.[A-Za-z0-9_])';
|
|
7
|
+
|
|
8
|
+
export function mentionsPerson(text, login) {
|
|
9
|
+
if (typeof login !== 'string' || !login) return false;
|
|
10
|
+
const escaped = login.replace(/[^A-Za-z0-9_-]/g, '\\$&');
|
|
11
|
+
return new RegExp(PERSON_MENTION_START + escaped + PERSON_MENTION_END, 'i').test(String(text ?? ''));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function roomNotificationsSuppressed(preference, now = Date.now()) {
|
|
15
|
+
return preference?.level === 'muted' || Date.parse(preference?.snoozed_until ?? '') > now;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function allowsRoomNotification(preference, text, login, now = Date.now()) {
|
|
19
|
+
if (roomNotificationsSuppressed(preference, now)) return false;
|
|
20
|
+
return preference?.level !== 'mentions' || mentionsPerson(text, login);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function roomNotificationSnoozeUntil(preset, now = new Date()) {
|
|
24
|
+
const date = new Date(now);
|
|
25
|
+
if (preset === 'tomorrow') {
|
|
26
|
+
date.setDate(date.getDate() + 1);
|
|
27
|
+
date.setHours(9, 0, 0, 0);
|
|
28
|
+
} else if (preset === '1h' || preset === '8h') {
|
|
29
|
+
date.setTime(date.getTime() + (preset === '1h' ? 1 : 8) * 60 * 60 * 1000);
|
|
30
|
+
} else {
|
|
31
|
+
throw new Error('Choose a snooze duration.');
|
|
32
|
+
}
|
|
33
|
+
return date.toISOString();
|
|
34
|
+
}
|
|
@@ -5,12 +5,17 @@ export const ROOM_RESOURCE_AGENT_APPROVAL: "agent_approval";
|
|
|
5
5
|
export const ROOM_RESOURCE_EXECUTION_DELEGATION: "execution_delegation";
|
|
6
6
|
/** What agents are waiting for changed; consumers re-read the room's wake rules. */
|
|
7
7
|
export const ROOM_RESOURCE_WAKE_RULES: "wake_rules";
|
|
8
|
+
/** A reaction was added or removed; consumers re-read the reactions of the messages they show. */
|
|
9
|
+
export const ROOM_RESOURCE_MESSAGE_PINS: "message_pins";
|
|
10
|
+
export const ROOM_RESOURCE_MESSAGE_REACTIONS: "message_reactions";
|
|
8
11
|
/** Protocol-known references; consumers independently choose what they render. */
|
|
9
12
|
export const ROOM_RESOURCE_INVALIDATION_RESOURCES: readonly [
|
|
10
13
|
"agent_work",
|
|
11
14
|
"agent_approval",
|
|
12
15
|
"execution_delegation",
|
|
13
16
|
"wake_rules",
|
|
17
|
+
"message_reactions",
|
|
18
|
+
"message_pins",
|
|
14
19
|
];
|
|
15
20
|
|
|
16
21
|
export type RoomResourceInvalidationResource =
|