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.
Files changed (67) hide show
  1. package/README.md +0 -2
  2. package/dist/mcp/codex-session/app-server.js +16 -2
  3. package/dist/mcp/codex-session/session-start.js +1 -1
  4. package/dist/mcp/server/runtime/messages.js +18 -1
  5. package/dist/mcp/server/tools/tasks/verdict-tools.js +1 -1
  6. package/dist/mcp/server/tools/wake-rules.js +2 -1
  7. package/dist/shared/presence-change-channel.js +2 -0
  8. package/package.json +2 -2
  9. package/shared/agent-branch.d.mts +5 -0
  10. package/shared/agent-branch.mjs +28 -0
  11. package/shared/code-block.css +193 -0
  12. package/shared/code-highlighting.d.mts +109 -0
  13. package/shared/code-highlighting.mjs +345 -0
  14. package/shared/codex-owner-isolation.d.mts +34 -0
  15. package/shared/codex-owner-isolation.mjs +210 -0
  16. package/shared/message-link-preview-store.d.mts +10 -0
  17. package/shared/message-link-preview-store.mjs +77 -0
  18. package/shared/message-link-previews.d.mts +16 -0
  19. package/shared/message-link-previews.mjs +70 -0
  20. package/shared/message-pin-store.d.mts +20 -0
  21. package/shared/message-pin-store.mjs +102 -0
  22. package/shared/message-pins.d.mts +25 -0
  23. package/shared/message-pins.mjs +16 -0
  24. package/shared/message-reaction-store.d.mts +51 -0
  25. package/shared/message-reaction-store.mjs +290 -0
  26. package/shared/message-reactions.d.mts +73 -0
  27. package/shared/message-reactions.mjs +130 -0
  28. package/shared/message-search-controller.d.mts +38 -0
  29. package/shared/message-search-controller.mjs +99 -0
  30. package/shared/message-search.d.mts +46 -0
  31. package/shared/message-search.mjs +114 -0
  32. package/shared/paced-queue.d.mts +28 -0
  33. package/shared/paced-queue.mjs +159 -0
  34. package/shared/permission-review.d.mts +19 -0
  35. package/shared/permission-review.mjs +238 -15
  36. package/shared/room-message-urls.d.mts +25 -0
  37. package/shared/room-message-urls.mjs +119 -0
  38. package/shared/room-notification-client.d.mts +18 -0
  39. package/shared/room-notification-client.mjs +140 -0
  40. package/shared/room-notification-preferences.d.mts +17 -0
  41. package/shared/room-notification-preferences.mjs +34 -0
  42. package/shared/room-resource-invalidation.d.mts +5 -0
  43. package/shared/room-resource-invalidation.mjs +6 -0
  44. package/shared/room-turn-no-reply.d.mts +1 -0
  45. package/shared/room-turn-no-reply.mjs +1 -0
  46. package/shared/room-typing.d.mts +16 -0
  47. package/shared/room-typing.mjs +112 -0
  48. package/shared/room-unread-client.ts +128 -0
  49. package/shared/room-unread.d.mts +25 -0
  50. package/shared/room-unread.mjs +57 -0
  51. package/shared/room-unread.test.mjs +91 -0
  52. package/shared/rooms/RoomsDirectory.vue +1 -1
  53. package/shared/rooms/directory.ts +1 -0
  54. package/shared/slash-commands.d.mts +31 -0
  55. package/shared/slash-commands.mjs +48 -0
  56. package/shared/slash-commands.test.mjs +26 -0
  57. package/shared/ui/MessageReactionBar.vue +65 -0
  58. package/shared/ui/MessageReactionPicker.vue +291 -0
  59. package/shared/ui/MessageSearchResults.vue +106 -0
  60. package/shared/ui/PinnedMessages.vue +124 -0
  61. package/shared/ui/TypingIndicator.vue +22 -0
  62. package/shared/ui/WakeRulesPanel.vue +1 -0
  63. package/shared/ui/message-reactions.css +254 -0
  64. package/shared/ui/message-search.css +186 -0
  65. package/shared/ui/useComposerSlashCommands.ts +92 -0
  66. package/shared/wake-rules.d.mts +8 -3
  67. package/shared/wake-rules.mjs +20 -4
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <div v-if="reactions.length" class="message-reactions" role="group" aria-label="Reactions">
3
+ <component
4
+ :is="canReact ? 'button' : 'span'"
5
+ v-for="reaction in reactions"
6
+ :key="reaction.emoji"
7
+ class="message-reaction"
8
+ :type="canReact ? 'button' : undefined"
9
+ :role="canReact ? undefined : 'img'"
10
+ :aria-pressed="canReact ? viewerReacted(reaction.emoji) : undefined"
11
+ :aria-label="label(reaction)"
12
+ :title="label(reaction)"
13
+ @click="canReact && emit('toggle', reaction.emoji)"
14
+ >
15
+ <span class="message-reaction-emoji" aria-hidden="true">{{ reaction.emoji }}</span>
16
+ <span class="message-reaction-count" aria-hidden="true">{{ reaction.count }}</span>
17
+ </component>
18
+ <button
19
+ v-if="canReact"
20
+ class="message-reaction message-reaction-add"
21
+ type="button"
22
+ aria-label="Add reaction"
23
+ title="Add reaction"
24
+ aria-haspopup="dialog"
25
+ :aria-expanded="pickerOpen ?? false"
26
+ @click="emit('add', $event)"
27
+ >
28
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
29
+ <path d="M22 11v1a10 10 0 1 1-9-10" />
30
+ <path d="M8 14s1.5 2 4 2 4-2 4-2" />
31
+ <path d="M9 9h.01M15 9h.01" />
32
+ <path d="M16 5h6M19 2v6" />
33
+ </svg>
34
+ </button>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { describeMessageReaction, type MessageReaction } from "../message-reactions.mjs";
40
+
41
+ const props = defineProps<{
42
+ reactions: readonly MessageReaction[];
43
+ viewerLogin: string | null;
44
+ /** Whether the viewer reacted with this emoji, including when they are past the listed reactors. */
45
+ viewerReacted: (emoji: string) => boolean;
46
+ canReact: boolean;
47
+ pickerOpen?: boolean;
48
+ }>();
49
+
50
+ const emit = defineEmits<{
51
+ toggle: [emoji: string];
52
+ /** The click that asked for the picker; its target is the anchor. */
53
+ add: [event: MouseEvent];
54
+ }>();
55
+
56
+ function label(reaction: MessageReaction): string {
57
+ const description = describeMessageReaction(reaction, props.viewerLogin);
58
+ // The list may not name the viewer when many people reacted.
59
+ return props.viewerReacted(reaction.emoji) && !description.startsWith("You")
60
+ ? `You and others reacted with ${reaction.emoji}`
61
+ : description;
62
+ }
63
+ </script>
64
+
65
+ <style src="./message-reactions.css"></style>
@@ -0,0 +1,291 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ v-if="anchor"
5
+ ref="panel"
6
+ class="message-reaction-picker"
7
+ role="dialog"
8
+ aria-label="Add a reaction"
9
+ :style="{ left: `${position.left}px`, top: `${position.top}px` }"
10
+ :data-side="position.side"
11
+ :data-align="position.align"
12
+ :data-placed="placed"
13
+ :data-instant="instant"
14
+ @keydown="handleKeydown"
15
+ @focusout="handleFocusOut"
16
+ @pointerdown.stop
17
+ @contextmenu.stop
18
+ >
19
+ <div class="message-reaction-picker-grid" role="group" aria-label="Common reactions">
20
+ <button
21
+ v-for="(emoji, index) in MESSAGE_REACTION_QUICK_EMOJI"
22
+ :key="emoji"
23
+ class="message-reaction-picker-emoji"
24
+ type="button"
25
+ :tabindex="index === activeIndex ? 0 : -1"
26
+ :aria-pressed="viewerReacted(emoji)"
27
+ @click="choose(emoji)"
28
+ @focus="activeIndex = index"
29
+ >
30
+ {{ emoji }}
31
+ </button>
32
+ </div>
33
+ <form class="message-reaction-picker-custom" novalidate @submit.prevent="chooseCustom">
34
+ <input
35
+ ref="customInput"
36
+ v-model="custom"
37
+ class="message-reaction-picker-input"
38
+ type="text"
39
+ autocomplete="off"
40
+ autocapitalize="off"
41
+ spellcheck="false"
42
+ maxlength="32"
43
+ placeholder="Or type any emoji, then Enter"
44
+ aria-label="React with any emoji"
45
+ :aria-invalid="customInvalid"
46
+ :aria-describedby="customInvalid ? hintId : undefined"
47
+ @input="customInvalid = false"
48
+ />
49
+ <p v-if="customInvalid" :id="hintId" class="message-reaction-picker-hint" role="alert">
50
+ Enter one emoji.
51
+ </p>
52
+ </form>
53
+ </div>
54
+ </Teleport>
55
+ </template>
56
+
57
+ <script setup lang="ts">
58
+ import { nextTick, onBeforeUnmount, reactive, ref, useId, watch } from "vue";
59
+ import { MESSAGE_REACTION_QUICK_EMOJI, normalizeMessageReactionEmoji } from "../message-reactions.mjs";
60
+
61
+ export interface MessageReactionPickerAnchor {
62
+ element: HTMLElement;
63
+ /** Context-menu position, kept relative to the message as it scrolls. */
64
+ point?: { x: number; y: number };
65
+ }
66
+
67
+ const COLUMNS = 8;
68
+ const GAP = 6;
69
+ const MARGIN = 8;
70
+
71
+ const props = defineProps<{
72
+ /** Live control or message anchoring the picker; null closes it. */
73
+ anchor: MessageReactionPickerAnchor | null;
74
+ viewerReacted: (emoji: string) => boolean;
75
+ /** Opened from the keyboard: appear without motion. */
76
+ instant?: boolean;
77
+ }>();
78
+
79
+ const emit = defineEmits<{
80
+ select: [emoji: string];
81
+ /**
82
+ * `restoreFocus` is true when the person dismissed it from the keyboard or
83
+ * chose an emoji. `pressed` is what a dismissing press landed on, so the
84
+ * host can tell a press on the control that opened the picker.
85
+ */
86
+ close: [restoreFocus: boolean, pressed?: EventTarget | null];
87
+ }>();
88
+
89
+ const panel = ref<HTMLElement | null>(null);
90
+ const customInput = ref<HTMLInputElement | null>(null);
91
+ const activeIndex = ref(0);
92
+ const custom = ref("");
93
+ const customInvalid = ref(false);
94
+ const placed = ref(false);
95
+ const position = reactive({ left: 0, top: 0, side: "above" as "above" | "below", align: "start" as "start" | "end" });
96
+ const hintId = useId();
97
+ let frame: number | null = null;
98
+ let pointOffset: { x: number; y: number } | null = null;
99
+ let focusOnPlacement = false;
100
+ let scrollViewport: HTMLElement | null = null;
101
+
102
+ function nearestScrollViewport(element: HTMLElement): HTMLElement | null {
103
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
104
+ if (/^(auto|scroll)$/.test(window.getComputedStyle(parent).overflowY)) return parent;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function place(): void {
110
+ const element = panel.value;
111
+ const source = props.anchor;
112
+ if (!element || !source) return;
113
+ if (!source.element.isConnected) {
114
+ dismiss();
115
+ return;
116
+ }
117
+ // All geometry is read in this animation frame, before reactive style writes.
118
+ const rect = source.element.getBoundingClientRect();
119
+ const viewportWidth = window.innerWidth;
120
+ const viewportHeight = window.innerHeight;
121
+ const clip = scrollViewport?.getBoundingClientRect();
122
+ const visible = {
123
+ left: Math.max(0, clip?.left ?? 0),
124
+ right: Math.min(viewportWidth, clip?.right ?? viewportWidth),
125
+ top: Math.max(0, clip?.top ?? 0),
126
+ bottom: Math.min(viewportHeight, clip?.bottom ?? viewportHeight),
127
+ };
128
+ if (source.point && !pointOffset) {
129
+ pointOffset = { x: source.point.x - rect.left, y: source.point.y - rect.top };
130
+ }
131
+ const anchor = pointOffset
132
+ ? { left: rect.left + pointOffset.x, right: rect.left + pointOffset.x,
133
+ top: rect.top + pointOffset.y, bottom: rect.top + pointOffset.y }
134
+ : rect;
135
+ // A context menu follows its click point, even when the rest of a tall
136
+ // message is still visible. Controls stay anchored while partially visible.
137
+ if (anchor.bottom <= visible.top || anchor.top >= visible.bottom
138
+ || anchor.right <= visible.left || anchor.left >= visible.right
139
+ || visible.bottom <= visible.top || visible.right <= visible.left) {
140
+ dismiss();
141
+ return;
142
+ }
143
+ const { width, height } = element.getBoundingClientRect();
144
+ const fitsAbove = anchor.top - GAP - height >= MARGIN;
145
+ const fitsBelow = anchor.bottom + GAP + height <= viewportHeight - MARGIN;
146
+ position.side = fitsAbove || !fitsBelow ? "above" : "below";
147
+ const top = position.side === "above" ? anchor.top - GAP - height : anchor.bottom + GAP;
148
+ // Grow away from whichever edge of the window the control is nearer to.
149
+ position.align = (anchor.left + anchor.right) / 2 > viewportWidth / 2 ? "end" : "start";
150
+ const left = position.align === "end" ? anchor.right - width : anchor.left;
151
+ position.left = Math.round(Math.min(Math.max(left, MARGIN), Math.max(MARGIN, viewportWidth - width - MARGIN)));
152
+ position.top = Math.round(Math.min(Math.max(top, MARGIN), Math.max(MARGIN, viewportHeight - height - MARGIN)));
153
+ placed.value = true;
154
+ }
155
+
156
+ function schedulePlace(): void {
157
+ if (frame !== null || !listening) return;
158
+ frame = window.requestAnimationFrame(() => {
159
+ frame = null;
160
+ place();
161
+ if (focusOnPlacement && placed.value) {
162
+ focusOnPlacement = false;
163
+ // Position must be rendered before the initially hidden picker can focus.
164
+ void nextTick(() => { if (listening && props.anchor) focusEmoji(0); });
165
+ }
166
+ });
167
+ }
168
+
169
+ function focusEmoji(index: number): void {
170
+ const count = MESSAGE_REACTION_QUICK_EMOJI.length;
171
+ activeIndex.value = Math.min(Math.max(index, 0), count - 1);
172
+ panel.value?.querySelectorAll<HTMLButtonElement>(".message-reaction-picker-emoji")[activeIndex.value]?.focus();
173
+ }
174
+
175
+ function choose(emoji: string): void {
176
+ emit("select", emoji);
177
+ emit("close", true);
178
+ }
179
+
180
+ function chooseCustom(): void {
181
+ const emoji = normalizeMessageReactionEmoji(custom.value);
182
+ if (!emoji) {
183
+ customInvalid.value = true;
184
+ return;
185
+ }
186
+ choose(emoji);
187
+ }
188
+
189
+ function handleKeydown(event: KeyboardEvent): void {
190
+ if (event.key === "Escape") {
191
+ event.preventDefault();
192
+ event.stopPropagation();
193
+ emit("close", true);
194
+ return;
195
+ }
196
+ const inInput = event.target === customInput.value;
197
+ // There are two tab stops: the active emoji and the custom input. Exiting
198
+ // either end returns to the opener instead of losing focus in the teleport.
199
+ if (event.key === "Tab" && (event.shiftKey ? !inInput : inInput)) {
200
+ event.preventDefault();
201
+ event.stopPropagation();
202
+ emit("close", true);
203
+ return;
204
+ }
205
+ if (inInput) {
206
+ // Leave the caret keys to the text field, except the way back up to the grid.
207
+ if (event.key === "ArrowUp") {
208
+ event.preventDefault();
209
+ focusEmoji(activeIndex.value);
210
+ }
211
+ return;
212
+ }
213
+ const index = activeIndex.value;
214
+ const last = MESSAGE_REACTION_QUICK_EMOJI.length - 1;
215
+ const moves: Record<string, number | "input"> = {
216
+ ArrowRight: Math.min(index + 1, last),
217
+ ArrowLeft: Math.max(index - 1, 0),
218
+ ArrowUp: index - COLUMNS >= 0 ? index - COLUMNS : index,
219
+ ArrowDown: index + COLUMNS <= last ? index + COLUMNS : "input",
220
+ Home: 0,
221
+ End: last,
222
+ };
223
+ const move = moves[event.key];
224
+ if (move === undefined) return;
225
+ event.preventDefault();
226
+ if (move === "input") customInput.value?.focus();
227
+ else focusEmoji(move);
228
+ }
229
+
230
+ function handleFocusOut(event: FocusEvent): void {
231
+ // Focus left the picker for something else in the app: it is done.
232
+ const next = event.relatedTarget;
233
+ if (next instanceof Node && !panel.value?.contains(next)) emit("close", false);
234
+ }
235
+
236
+ function dismiss(): void {
237
+ emit("close", false);
238
+ }
239
+
240
+ // These listen in the capture phase, so they also see events from inside the picker.
241
+ function dismissUnlessInside(event: Event): void {
242
+ if (event.target instanceof Node && panel.value?.contains(event.target)) return;
243
+ emit("close", false, event.type === "pointerdown" ? event.target : null);
244
+ }
245
+
246
+ let listening = false;
247
+ function listen(on: boolean): void {
248
+ if (on === listening) return;
249
+ listening = on;
250
+ if (on) {
251
+ window.addEventListener("pointerdown", dismissUnlessInside, true);
252
+ window.addEventListener("scroll", schedulePlace, true);
253
+ window.addEventListener("resize", schedulePlace);
254
+ window.addEventListener("blur", dismiss);
255
+ } else {
256
+ window.removeEventListener("pointerdown", dismissUnlessInside, true);
257
+ window.removeEventListener("scroll", schedulePlace, true);
258
+ window.removeEventListener("resize", schedulePlace);
259
+ window.removeEventListener("blur", dismiss);
260
+ if (frame !== null) window.cancelAnimationFrame(frame);
261
+ frame = null;
262
+ focusOnPlacement = false;
263
+ scrollViewport = null;
264
+ }
265
+ }
266
+
267
+ watch(
268
+ () => props.anchor,
269
+ async (anchor, previous) => {
270
+ if (anchor && !previous) {
271
+ placed.value = false;
272
+ custom.value = "";
273
+ customInvalid.value = false;
274
+ activeIndex.value = 0;
275
+ pointOffset = null;
276
+ scrollViewport = nearestScrollViewport(anchor.element);
277
+ focusOnPlacement = true;
278
+ listen(true);
279
+ await nextTick();
280
+ schedulePlace();
281
+ } else if (!anchor && previous) {
282
+ listen(false);
283
+ }
284
+ },
285
+ { immediate: true },
286
+ );
287
+
288
+ onBeforeUnmount(() => listen(false));
289
+ </script>
290
+
291
+ <style src="./message-reactions.css"></style>
@@ -0,0 +1,106 @@
1
+ <template>
2
+ <section v-if="visible" class="history-search" aria-label="Matches in this room">
3
+ <p v-if="status === 'loading'" class="history-search-note" role="status">Searching earlier messages…</p>
4
+ <p v-else-if="status === 'invalid'" class="history-search-note" role="status">{{ invalidText }}</p>
5
+ <p v-else-if="status === 'error'" class="history-search-note history-search-error" role="alert">{{ error }}</p>
6
+ <p v-else-if="!hits.length && !hasMore" class="history-search-note" role="status">No messages in this room match.</p>
7
+ <template v-else>
8
+ <h3 class="history-search-heading">
9
+ <span>All matches in this room</span>
10
+ <span class="history-search-count">{{ hits.length }}{{ hasMore ? "+" : "" }}</span>
11
+ </h3>
12
+ <ul class="history-search-list">
13
+ <li v-for="hit in hits" :key="hit.id" class="history-search-hit" :data-expanded="expanded.has(hit.id)">
14
+ <button
15
+ class="history-search-hit-toggle"
16
+ type="button"
17
+ :aria-expanded="expanded.has(hit.id)"
18
+ @click="toggle(hit.id)"
19
+ >
20
+ <span class="history-search-hit-meta">
21
+ <strong>{{ hit.sender }}</strong>
22
+ <time :datetime="hit.timestamp">{{ formatTime(hit.timestamp) }}</time>
23
+ </span>
24
+ <span class="history-search-hit-text">
25
+ <template v-for="(segment, index) in segments(hit)" :key="index">
26
+ <mark v-if="segment.match">{{ segment.text }}</mark>
27
+ <template v-else>{{ segment.text }}</template>
28
+ </template>
29
+ </span>
30
+ </button>
31
+ <button class="history-search-hit-show" type="button" @click="emit('show', hit.id)">Show in room</button>
32
+ </li>
33
+ </ul>
34
+ <p v-if="error" class="history-search-note history-search-error" role="alert">{{ error }}</p>
35
+ <button v-if="hasMore" class="history-search-more" type="button" :disabled="loadingMore" @click="emit('more')">
36
+ {{ loadingMore ? "Loading…" : "Show more" }}
37
+ </button>
38
+ </template>
39
+ </section>
40
+ </template>
41
+
42
+ <script setup lang="ts">
43
+ import { computed, ref, watch } from "vue";
44
+ import {
45
+ MESSAGE_SEARCH_MAX_QUERY_CHARS,
46
+ MESSAGE_SEARCH_MAX_TERMS,
47
+ highlightMessageSearchText,
48
+ messageSearchSnippet,
49
+ type MessageSearchSegment,
50
+ } from "../message-search.mjs";
51
+
52
+ export interface MessageSearchHit {
53
+ id: string;
54
+ /** The name people see for the sender. */
55
+ sender: string;
56
+ timestamp: string;
57
+ /** The text people read; rendered as plain text. */
58
+ text: string;
59
+ }
60
+
61
+ const props = defineProps<{
62
+ status: "idle" | "invalid" | "loading" | "ready" | "error";
63
+ /** Every match in the room's history, newest first. */
64
+ hits: readonly MessageSearchHit[];
65
+ terms: readonly string[];
66
+ hasMore: boolean;
67
+ loadingMore: boolean;
68
+ error: string | null;
69
+ /**
70
+ * How many matches the open timeline's own find already shows. It also
71
+ * matches sender and attachment names, so it can find what this list cannot;
72
+ * the list then stays quiet instead of claiming there is nothing.
73
+ */
74
+ loadedMatchCount: number;
75
+ formatTime: (timestamp: string) => string;
76
+ }>();
77
+
78
+ const emit = defineEmits<{
79
+ /** Bring this message into view in the room. */
80
+ show: [messageId: string];
81
+ more: [];
82
+ }>();
83
+
84
+ const expanded = ref(new Set<string>());
85
+ // A new search starts with every result collapsed.
86
+ watch(() => props.terms.join("\n"), () => { expanded.value = new Set(); });
87
+
88
+ function toggle(messageId: string): void {
89
+ const next = new Set(expanded.value);
90
+ if (!next.delete(messageId)) next.add(messageId);
91
+ expanded.value = next;
92
+ }
93
+
94
+ function segments(hit: MessageSearchHit): MessageSearchSegment[] {
95
+ const text = expanded.value.has(hit.id) ? hit.text : messageSearchSnippet(hit.text, props.terms);
96
+ return highlightMessageSearchText(text, props.terms);
97
+ }
98
+
99
+ const invalidText = computed(() => props.error === "too_many_terms"
100
+ ? `Search for at most ${MESSAGE_SEARCH_MAX_TERMS} words or quoted phrases.`
101
+ : `Search for at most ${MESSAGE_SEARCH_MAX_QUERY_CHARS} characters.`);
102
+ const visible = computed(() => props.status !== "idle"
103
+ && !(props.status === "ready" && !props.hits.length && !props.hasMore && props.loadedMatchCount > 0));
104
+ </script>
105
+
106
+ <style src="./message-search.css"></style>
@@ -0,0 +1,124 @@
1
+ <template>
2
+ <div v-if="pins.length" class="message-pins-row">
3
+ <button ref="trigger" type="button" class="message-pins-trigger" aria-haspopup="dialog"
4
+ :aria-expanded="open" @click="toggle">
5
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
6
+ <path d="M16 3 21 8M17 4 9 12 5 13 11 19 12 15 20 7M2 22 8 16" />
7
+ </svg>
8
+ Pinned ({{ pins.length }})
9
+ </button>
10
+ <Teleport to="body">
11
+ <section v-if="open" ref="panel" class="message-pins-panel" role="dialog" aria-label="Pinned messages"
12
+ :style="position" @keydown="onKeydown">
13
+ <p v-if="loading" role="status">Updating pins…</p>
14
+ <p v-if="error" role="alert">{{ error }} <button type="button" @click="$emit('refresh')">Retry</button></p>
15
+ <ol aria-label="Pinned messages">
16
+ <li v-for="(pin, index) in pins" :key="pin.message_id">
17
+ <button type="button" data-pin-entry :tabindex="index === active ? 0 : -1"
18
+ @focus="active = index" @click="choose(pin.message_id)">
19
+ <span class="message-pin-byline"><strong>{{ pin.sender }}</strong> <time :datetime="pin.timestamp">{{ timeLabel(pin.timestamp) }}</time></span>
20
+ <span class="message-pin-snippet">{{ pin.snippet || 'Message without text' }}</span>
21
+ <span class="message-pin-attribution">Pinned by {{ pin.pinned_by.name }}</span>
22
+ </button>
23
+ </li>
24
+ </ol>
25
+ </section>
26
+ </Teleport>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
32
+ import type { MessagePin } from "../message-pins.mjs";
33
+
34
+ const props = defineProps<{ pins: readonly MessagePin[]; loading?: boolean; error?: string | null }>();
35
+ const emit = defineEmits<{ reveal: [messageId: string]; refresh: [] }>();
36
+ const trigger = ref<HTMLButtonElement | null>(null);
37
+ const panel = ref<HTMLElement | null>(null);
38
+ const open = ref(false);
39
+ const active = ref(0);
40
+ const left = ref(8);
41
+ const top = ref(8);
42
+ const maxHeight = ref(360);
43
+ const position = computed(() => ({ left: `${left.value}px`, top: `${top.value}px`, maxHeight: `${maxHeight.value}px` }));
44
+ function place(): void {
45
+ const bounds = trigger.value?.getBoundingClientRect();
46
+ if (!bounds) return;
47
+ left.value = Math.max(8, Math.min(bounds.left, window.innerWidth - Math.min(420, window.innerWidth - 16) - 8));
48
+ top.value = Math.min(bounds.bottom + 4, window.innerHeight - 80);
49
+ maxHeight.value = Math.max(64, Math.min(360, window.innerHeight - top.value - 8));
50
+ }
51
+ function focusEntry(): void {
52
+ panel.value?.querySelectorAll<HTMLButtonElement>("[data-pin-entry]")[active.value]?.focus();
53
+ }
54
+ async function toggle(): Promise<void> {
55
+ if (open.value) { close(); return; }
56
+ active.value = 0;
57
+ place();
58
+ open.value = true;
59
+ await nextTick();
60
+ focusEntry();
61
+ }
62
+ function close(restoreFocus = true): void {
63
+ open.value = false;
64
+ if (restoreFocus) trigger.value?.focus();
65
+ }
66
+ function choose(id: string): void { close(); emit("reveal", id); }
67
+ function onKeydown(event: KeyboardEvent): void {
68
+ if (event.key === "Escape" || event.key === "Tab") { event.preventDefault(); event.stopPropagation(); close(); return; }
69
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
70
+ event.preventDefault();
71
+ active.value = event.key === "Home" ? 0 : event.key === "End" ? props.pins.length - 1
72
+ : (active.value + (event.key === "ArrowDown" ? 1 : -1) + props.pins.length) % props.pins.length;
73
+ focusEntry();
74
+ }
75
+ function outside(event: PointerEvent): void {
76
+ const target = event.target as Node | null;
77
+ if (target && !trigger.value?.contains(target) && !panel.value?.contains(target)) { close(false); }
78
+ }
79
+ function timeLabel(value: string): string {
80
+ const date = new Date(value);
81
+ return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
82
+ }
83
+ watch(open, (value) => {
84
+ if (value) {
85
+ document.addEventListener("pointerdown", outside, true);
86
+ window.addEventListener("resize", place);
87
+ } else cleanup();
88
+ }, { flush: "sync" });
89
+ watch(() => props.pins.length, (length) => {
90
+ if (!length) {
91
+ const region = trigger.value?.parentElement?.parentElement;
92
+ const focusWasInside = panel.value?.contains(document.activeElement) || document.activeElement === trigger.value;
93
+ close(false);
94
+ if (focusWasInside && region) {
95
+ region.tabIndex = -1;
96
+ void nextTick(() => region.focus({ preventScroll: true }));
97
+ }
98
+ }
99
+ else active.value = Math.min(active.value, length - 1);
100
+ });
101
+ function cleanup(): void {
102
+ document.removeEventListener("pointerdown", outside, true);
103
+ window.removeEventListener("resize", place);
104
+ }
105
+ onBeforeUnmount(cleanup);
106
+ </script>
107
+
108
+ <style>
109
+ .message-pins-row { flex: 0 0 auto; position: relative; padding: 3px 12px; border-bottom: 1px solid var(--border); }
110
+ .message-pins-trigger { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 4px; background: transparent; color: var(--text-secondary); font: inherit; font-size: 12px; cursor: pointer; padding: 4px; }
111
+ .message-pins-panel { position: fixed; z-index: 1200; box-sizing: border-box; width: min(420px, calc(100vw - 16px)); overflow-y: auto; overscroll-behavior: contain; padding: 8px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--bg-elevated); color: var(--text); box-shadow: var(--shadow-lg); font-family: var(--font-sans, inherit); font-size: 13px; }
112
+ .message-pins-panel ol { list-style: none; margin: 0; padding: 0; }
113
+ .message-pins-panel [data-pin-entry] { display: flex; flex-direction: column; gap: 4px; width: 100%; border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; text-align: left; padding: 9px; cursor: pointer; }
114
+ .message-pins-trigger:focus-visible, .message-pins-panel button:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
115
+ @media (hover: hover) and (pointer: fine) {
116
+ .message-pins-trigger:hover, .message-pins-panel [data-pin-entry]:hover { background: var(--accent-hover); color: var(--text); }
117
+ }
118
+ .message-pins-trigger:active, .message-pins-panel [data-pin-entry]:active { background: var(--accent-active); color: var(--text); }
119
+ .message-pin-byline { display: flex; flex-wrap: wrap; gap: 8px; }
120
+ .message-pin-byline time, .message-pin-attribution { font-size: 11px; color: var(--text-secondary); }
121
+ .message-pin-snippet { overflow-wrap: anywhere; white-space: pre-wrap; }
122
+ .message-pin-marker { display: inline-flex; vertical-align: middle; color: var(--text-secondary); margin-inline: 4px; }
123
+ .message-pin-marker svg, .message-pins-trigger svg { display: block; width: 13px; height: 13px; flex: 0 0 auto; }
124
+ </style>
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <span class="room-typing-indicator" role="status" aria-live="polite" aria-atomic="true">{{ label }}</span>
3
+ </template>
4
+ <script setup lang="ts">
5
+ defineProps<{ label: string }>();
6
+ </script>
7
+ <style scoped>
8
+ .room-typing-indicator {
9
+ position: absolute;
10
+ top: 0;
11
+ left: 18px;
12
+ right: 18px;
13
+ height: 12px;
14
+ overflow: hidden;
15
+ white-space: nowrap;
16
+ text-overflow: ellipsis;
17
+ pointer-events: none;
18
+ color: var(--text-tertiary, var(--muted));
19
+ font-size: 11px;
20
+ line-height: 12px;
21
+ }
22
+ </style>
@@ -146,6 +146,7 @@ function rowHasFocus(ruleId: string): boolean {
146
146
  function outcomeLabel(rule: WakeRule): string {
147
147
  if (rule.status === "fired" || (rule.status === "active" && rule.fire_count > 0)) return "Woke";
148
148
  if (rule.status === "expired") return "Stopped waiting";
149
+ if (rule.status === "retired") return rule.ended_reason ? `Ended: ${rule.ended_reason}` : "Ended";
149
150
  return rule.cancelled_by ? `Cancelled by ${rule.cancelled_by.label}` : "Cancelled";
150
151
  }
151
152