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
|
@@ -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) {
|
|
@@ -46,3 +46,19 @@ export declare function estimateRoomAgentGuidelineTokens(value: unknown): number
|
|
|
46
46
|
export declare function normalizeGitHubRoomChatEventKinds(value: unknown): GitHubRoomChatEventKind[] | null;
|
|
47
47
|
/** The kind a repository event is filed under, or null when it has none. */
|
|
48
48
|
export declare function githubRoomChatEventKind(event: { kind?: string } | null | undefined): GitHubRoomChatEventKind | null;
|
|
49
|
+
|
|
50
|
+
export type RoomAgentReplyOrder = "sequential" | "parallel";
|
|
51
|
+
export declare const ROOM_AGENT_REPLY_ORDERS: readonly RoomAgentReplyOrder[];
|
|
52
|
+
export declare const DEFAULT_ROOM_AGENT_REPLY_ORDER: RoomAgentReplyOrder;
|
|
53
|
+
|
|
54
|
+
export interface RoomAgentReplyOrderSetting {
|
|
55
|
+
room_id: string;
|
|
56
|
+
/** The order that applies; "parallel" when the room has not chosen. */
|
|
57
|
+
order: RoomAgentReplyOrder;
|
|
58
|
+
/** Whether a room admin chose it, rather than the default applying. */
|
|
59
|
+
chosen: boolean;
|
|
60
|
+
can_manage: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A known order, or null when the input is not one. */
|
|
64
|
+
export declare function normalizeRoomAgentReplyOrder(value: unknown): RoomAgentReplyOrder | null;
|
package/shared/room-settings.mjs
CHANGED
|
@@ -106,3 +106,17 @@ export function githubRoomChatEventKind(event) {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How agents answer a message that activates several of them at once (a
|
|
112
|
+
* broadcast, or the small-room fallback). "parallel" — the default when a
|
|
113
|
+
* room has not chosen — wakes them all together; "sequential", which a room
|
|
114
|
+
* admin turns on, gives each agent a turn after the one before has answered.
|
|
115
|
+
*/
|
|
116
|
+
export const ROOM_AGENT_REPLY_ORDERS = Object.freeze(["sequential", "parallel"]);
|
|
117
|
+
export const DEFAULT_ROOM_AGENT_REPLY_ORDER = "parallel";
|
|
118
|
+
|
|
119
|
+
/** A known order, or null when the input is not one. */
|
|
120
|
+
export function normalizeRoomAgentReplyOrder(value) {
|
|
121
|
+
return ROOM_AGENT_REPLY_ORDERS.includes(value) ? value : null;
|
|
122
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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; names(): string[]; label(): string; nextExpiry(): number | null;
|
|
13
|
+
};
|
|
14
|
+
export function typingLabel(names: readonly string[]): string;
|
|
15
|
+
export function typingSentence(names: readonly string[]): Array<{ text: string; name: boolean }>;
|
|
16
|
+
export function createTypingDisplay(update: (label: string, names: string[]) => void, clock?: {
|
|
17
|
+
now?: () => number; schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
|
|
18
|
+
}): { receive(raw: unknown, self: string | null): void; clear(): void };
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
/** Plain-text sentence for assistive technology and compact surfaces. */
|
|
64
|
+
export function typingLabel(names) {
|
|
65
|
+
if (names.length > 2) return 'Several people are typing…';
|
|
66
|
+
return names.length ? `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} typing…` : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The same sentence as parts, so a view can emphasise names without building HTML. */
|
|
70
|
+
export function typingSentence(names) {
|
|
71
|
+
const name = text => ({ text, name: true }), plain = text => ({ text, name: false });
|
|
72
|
+
if (names.length === 1) return [name(names[0]), plain(' is typing')];
|
|
73
|
+
if (names.length === 2) return [name(names[0]), plain(' and '), name(names[1]), plain(' are typing')];
|
|
74
|
+
if (names.length < 3) return [];
|
|
75
|
+
const others = names.length - 2;
|
|
76
|
+
return [name(names[0]), plain(', '), name(names[1]), plain(` and ${others} ${others === 1 ? 'other' : 'others'} are typing`)];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Sequence guards survive stops briefly; no snapshot or replay. */
|
|
80
|
+
export function createTypingReceiver(now = Date.now) {
|
|
81
|
+
const sources = new Map();
|
|
82
|
+
return {
|
|
83
|
+
receive(raw, self) {
|
|
84
|
+
const value = parseTypingSignal(raw);
|
|
85
|
+
if (!value || value.account_id === self) return;
|
|
86
|
+
const key = `${value.account_id}:${value.client_id}`;
|
|
87
|
+
const previous = sources.get(key);
|
|
88
|
+
if (previous && previous.sequence >= value.sequence) return;
|
|
89
|
+
if (!previous && sources.size >= TYPING.people * TYPING.sources) return;
|
|
90
|
+
sources.set(key, { ...value, until: now() + Math.min(value.ttl_ms, TYPING.ttl), forget: now() + TYPING.ttl });
|
|
91
|
+
},
|
|
92
|
+
clear() { sources.clear(); },
|
|
93
|
+
nextExpiry() {
|
|
94
|
+
let earliest = Infinity;
|
|
95
|
+
for (const value of sources.values()) {
|
|
96
|
+
if (value.typing && value.until > now()) earliest = Math.min(earliest, value.until);
|
|
97
|
+
}
|
|
98
|
+
return Number.isFinite(earliest) ? earliest : null;
|
|
99
|
+
},
|
|
100
|
+
/** One name per account, in a stable order, so a row does not reshuffle between refreshes. */
|
|
101
|
+
names() {
|
|
102
|
+
const people = new Map();
|
|
103
|
+
for (const [key, value] of sources) {
|
|
104
|
+
if (value.forget <= now()) sources.delete(key);
|
|
105
|
+
else if (value.typing && value.until > now()) people.set(value.account_id, value.name);
|
|
106
|
+
}
|
|
107
|
+
return [...people].sort(([a], [b]) => a.localeCompare(b)).map(([, name]) => name);
|
|
108
|
+
},
|
|
109
|
+
label() { return typingLabel(this.names()); },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A single receiver timeout, and no timer at all when nobody is typing. */
|
|
114
|
+
export function createTypingDisplay(update, { now = Date.now, schedule = setTimeout, cancel = clearTimeout } = {}) {
|
|
115
|
+
const receiver = createTypingReceiver(now);
|
|
116
|
+
let timer = null;
|
|
117
|
+
function refresh() {
|
|
118
|
+
if (timer !== null) cancel(timer);
|
|
119
|
+
timer = null;
|
|
120
|
+
const names = receiver.names();
|
|
121
|
+
update(typingLabel(names), names);
|
|
122
|
+
const expiry = receiver.nextExpiry();
|
|
123
|
+
if (expiry !== null) timer = schedule(refresh, Math.max(0, expiry - now()));
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
receive(raw, self) { receiver.receive(raw, self); refresh(); },
|
|
127
|
+
clear() { receiver.clear(); refresh(); },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -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
|
+
}
|