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,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
|
|