letagents 0.12.33 → 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/codex-session/app-server.js +16 -2
- package/dist/mcp/codex-session/session-start.js +1 -1
- package/dist/mcp/server/runtime/messages.js +18 -1
- package/dist/mcp/server/tools/tasks/verdict-tools.js +1 -1
- package/dist/mcp/server/tools/wake-rules.js +2 -1
- package/dist/shared/presence-change-channel.js +2 -0
- package/package.json +2 -2
- package/shared/agent-branch.d.mts +5 -0
- package/shared/agent-branch.mjs +28 -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.d.mts +34 -0
- package/shared/codex-owner-isolation.mjs +210 -0
- 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/paced-queue.d.mts +28 -0
- package/shared/paced-queue.mjs +159 -0
- package/shared/permission-review.d.mts +19 -0
- package/shared/permission-review.mjs +238 -15
- 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-turn-no-reply.d.mts +1 -0
- package/shared/room-turn-no-reply.mjs +1 -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/WakeRulesPanel.vue +1 -0
- package/shared/ui/message-reactions.css +254 -0
- package/shared/ui/message-search.css +186 -0
- package/shared/ui/useComposerSlashCommands.ts +92 -0
- package/shared/wake-rules.d.mts +8 -3
- package/shared/wake-rules.mjs +20 -4
|
@@ -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 =
|
|
@@ -6,6 +6,10 @@ export const ROOM_RESOURCE_AGENT_WORK = "agent_work";
|
|
|
6
6
|
export const ROOM_RESOURCE_AGENT_APPROVAL = "agent_approval";
|
|
7
7
|
export const ROOM_RESOURCE_EXECUTION_DELEGATION = "execution_delegation";
|
|
8
8
|
export const ROOM_RESOURCE_WAKE_RULES = "wake_rules";
|
|
9
|
+
// Someone added or removed an emoji reaction. Consumers re-read the reactions
|
|
10
|
+
// of the messages they show; the pointer never says which message changed.
|
|
11
|
+
export const ROOM_RESOURCE_MESSAGE_PINS = "message_pins";
|
|
12
|
+
export const ROOM_RESOURCE_MESSAGE_REACTIONS = "message_reactions";
|
|
9
13
|
// Protocol-known references. Each consumer still decides which surfaces, if
|
|
10
14
|
// any, react to a supported pointer.
|
|
11
15
|
export const ROOM_RESOURCE_INVALIDATION_RESOURCES = [
|
|
@@ -13,6 +17,8 @@ export const ROOM_RESOURCE_INVALIDATION_RESOURCES = [
|
|
|
13
17
|
ROOM_RESOURCE_AGENT_APPROVAL,
|
|
14
18
|
ROOM_RESOURCE_EXECUTION_DELEGATION,
|
|
15
19
|
ROOM_RESOURCE_WAKE_RULES,
|
|
20
|
+
ROOM_RESOURCE_MESSAGE_REACTIONS,
|
|
21
|
+
ROOM_RESOURCE_MESSAGE_PINS,
|
|
16
22
|
];
|
|
17
23
|
|
|
18
24
|
function exactKeys(value, keys) {
|
|
@@ -2,6 +2,7 @@ export const NO_REPLY_FAILURE: Readonly<{
|
|
|
2
2
|
outputLimit: "The model hit its output limit before writing a reply.";
|
|
3
3
|
emptyAnswer: "The model finished without writing a reply.";
|
|
4
4
|
contentFilter: "The model provider's content filter stopped the reply before any text was written.";
|
|
5
|
+
deniedTool: "The model stopped after one of its tool calls was denied, before it wrote a reply.";
|
|
5
6
|
}>;
|
|
6
7
|
export type NoReplyFailureKind = keyof typeof NO_REPLY_FAILURE;
|
|
7
8
|
export function noReplyFailureKind(detail: unknown): NoReplyFailureKind | null;
|
|
@@ -7,6 +7,7 @@ export const NO_REPLY_FAILURE = Object.freeze({
|
|
|
7
7
|
outputLimit: "The model hit its output limit before writing a reply.",
|
|
8
8
|
emptyAnswer: "The model finished without writing a reply.",
|
|
9
9
|
contentFilter: "The model provider's content filter stopped the reply before any text was written.",
|
|
10
|
+
deniedTool: "The model stopped after one of its tool calls was denied, before it wrote a reply.",
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
export function noReplyFailureKind(detail) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const ROOM_TYPING: 'room_typing_v1';
|
|
2
|
+
export const TYPING: Readonly<{ ttl: number; interval: number; sweep: number; sources: number; people: number; entries: number; name: number }>;
|
|
3
|
+
export interface TypingReport { client_id: string; sequence: number; typing: boolean; ttl_ms: number }
|
|
4
|
+
export interface TypingSignal extends TypingReport { room_id: string; account_id: string; name: string; expires_at: number }
|
|
5
|
+
export function parseTypingReport(value: unknown): TypingReport | null;
|
|
6
|
+
export function parseTypingSignal(value: unknown): TypingSignal | null;
|
|
7
|
+
export function createTypingSender(options: {
|
|
8
|
+
send: (report: TypingReport) => unknown; clientId: string; now?: () => number;
|
|
9
|
+
schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
|
|
10
|
+
}): { input(nonempty: boolean): void; stop(): void };
|
|
11
|
+
export function createTypingReceiver(now?: () => number): {
|
|
12
|
+
receive(raw: unknown, self: string | null): void; clear(): void; label(): string; nextExpiry(): number | null;
|
|
13
|
+
};
|
|
14
|
+
export function createTypingDisplay(update: (label: string) => void, clock?: {
|
|
15
|
+
now?: () => number; schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
|
|
16
|
+
}): { receive(raw: unknown, self: string | null): void; clear(): void };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export const ROOM_TYPING = 'room_typing_v1';
|
|
2
|
+
export const TYPING = Object.freeze({ ttl: 5000, interval: 2500, sweep: 1000, sources: 4, people: 512, entries: 10000, name: 80 });
|
|
3
|
+
|
|
4
|
+
export function parseTypingReport(value) {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
6
|
+
const { client_id, sequence, typing, ttl_ms } = value;
|
|
7
|
+
if (Object.keys(value).sort().join() !== 'client_id,sequence,ttl_ms,typing'
|
|
8
|
+
|| typeof client_id !== 'string' || !/^[a-zA-Z0-9_-]{16,64}$/.test(client_id)
|
|
9
|
+
|| !Number.isSafeInteger(sequence) || sequence < 1 || typeof typing !== 'boolean'
|
|
10
|
+
|| !Number.isInteger(ttl_ms) || ttl_ms < 0 || ttl_ms > TYPING.ttl
|
|
11
|
+
|| typing !== (ttl_ms > 0)) return null;
|
|
12
|
+
return { client_id, sequence, typing, ttl_ms };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseTypingSignal(value) {
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
17
|
+
const { room_id, account_id, name, expires_at, ...report } = value;
|
|
18
|
+
if (!parseTypingReport(report) || ![room_id, account_id, name].every(v => typeof v === 'string' && v.length > 0)
|
|
19
|
+
|| room_id.length > 512 || account_id.length > 128 || name.length > TYPING.name
|
|
20
|
+
|| !Number.isSafeInteger(expires_at)) return null;
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** No drafts enter this controller: input is only a nonempty/empty bit. */
|
|
25
|
+
export function createTypingSender({ send, clientId, now = Date.now, schedule = setTimeout, cancel = clearTimeout }) {
|
|
26
|
+
let sequence = 0, lastSent = -Infinity, lastInput = 0, active = false, reported = false, dirty = false, timer = null;
|
|
27
|
+
function report(typing) {
|
|
28
|
+
lastSent = now();
|
|
29
|
+
reported = typing;
|
|
30
|
+
dirty = false;
|
|
31
|
+
try {
|
|
32
|
+
void Promise.resolve(send({ client_id: clientId, sequence: ++sequence, typing,
|
|
33
|
+
ttl_ms: typing ? Math.max(1, TYPING.ttl - (now() - lastInput)) : 0 })).catch(() => {});
|
|
34
|
+
} catch { /* Reporting cannot interrupt composing, sending, or cleanup. */ }
|
|
35
|
+
}
|
|
36
|
+
function stop() {
|
|
37
|
+
if (timer !== null) cancel(timer);
|
|
38
|
+
timer = null;
|
|
39
|
+
if (reported) report(false);
|
|
40
|
+
active = false;
|
|
41
|
+
}
|
|
42
|
+
function tick() {
|
|
43
|
+
timer = null;
|
|
44
|
+
if (now() - lastInput >= TYPING.ttl) return stop();
|
|
45
|
+
if (dirty && now() - lastSent >= TYPING.interval) report(true);
|
|
46
|
+
timer = schedule(tick, Math.min(TYPING.interval, TYPING.ttl - (now() - lastInput)));
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
input(nonempty) {
|
|
50
|
+
if (!nonempty) return stop();
|
|
51
|
+
lastInput = now();
|
|
52
|
+
dirty = true;
|
|
53
|
+
if (!active) {
|
|
54
|
+
active = true;
|
|
55
|
+
if (now() - lastSent >= TYPING.interval) report(true);
|
|
56
|
+
}
|
|
57
|
+
if (timer === null) timer = schedule(tick, TYPING.interval);
|
|
58
|
+
},
|
|
59
|
+
stop,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Sequence guards survive stops briefly; no snapshot or replay. */
|
|
64
|
+
export function createTypingReceiver(now = Date.now) {
|
|
65
|
+
const sources = new Map();
|
|
66
|
+
return {
|
|
67
|
+
receive(raw, self) {
|
|
68
|
+
const value = parseTypingSignal(raw);
|
|
69
|
+
if (!value || value.account_id === self) return;
|
|
70
|
+
const key = `${value.account_id}:${value.client_id}`;
|
|
71
|
+
const previous = sources.get(key);
|
|
72
|
+
if (previous && previous.sequence >= value.sequence) return;
|
|
73
|
+
if (!previous && sources.size >= TYPING.people * TYPING.sources) return;
|
|
74
|
+
sources.set(key, { ...value, until: now() + Math.min(value.ttl_ms, TYPING.ttl), forget: now() + TYPING.ttl });
|
|
75
|
+
},
|
|
76
|
+
clear() { sources.clear(); },
|
|
77
|
+
nextExpiry() {
|
|
78
|
+
let earliest = Infinity;
|
|
79
|
+
for (const value of sources.values()) {
|
|
80
|
+
if (value.typing && value.until > now()) earliest = Math.min(earliest, value.until);
|
|
81
|
+
}
|
|
82
|
+
return Number.isFinite(earliest) ? earliest : null;
|
|
83
|
+
},
|
|
84
|
+
label() {
|
|
85
|
+
const people = new Map();
|
|
86
|
+
for (const [key, value] of sources) {
|
|
87
|
+
if (value.forget <= now()) sources.delete(key);
|
|
88
|
+
else if (value.typing && value.until > now()) people.set(value.account_id, value.name);
|
|
89
|
+
}
|
|
90
|
+
const names = [...people].sort(([a], [b]) => a.localeCompare(b)).map(([, name]) => name);
|
|
91
|
+
if (names.length > 2) return 'Several people are typing…';
|
|
92
|
+
return names.length ? `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} typing…` : '';
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A single receiver timeout, and no timer at all when nobody is typing. */
|
|
98
|
+
export function createTypingDisplay(update, { now = Date.now, schedule = setTimeout, cancel = clearTimeout } = {}) {
|
|
99
|
+
const receiver = createTypingReceiver(now);
|
|
100
|
+
let timer = null;
|
|
101
|
+
function refresh() {
|
|
102
|
+
if (timer !== null) cancel(timer);
|
|
103
|
+
timer = null;
|
|
104
|
+
update(receiver.label());
|
|
105
|
+
const expiry = receiver.nextExpiry();
|
|
106
|
+
if (expiry !== null) timer = schedule(refresh, Math.max(0, expiry - now()));
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
receive(raw, self) { receiver.receive(raw, self); refresh(); },
|
|
110
|
+
clear() { receiver.clear(); refresh(); },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -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
|
+
}
|