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.
Files changed (68) hide show
  1. package/README.md +0 -2
  2. package/dist/mcp/server/runtime/messages.js +18 -1
  3. package/dist/mcp/server/runtime/workspace-capture.js +16 -5
  4. package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
  5. package/package.json +5 -3
  6. package/shared/activation-routing.d.mts +21 -3
  7. package/shared/activation-routing.mjs +37 -0
  8. package/shared/code-block.css +193 -0
  9. package/shared/code-highlighting.d.mts +109 -0
  10. package/shared/code-highlighting.mjs +345 -0
  11. package/shared/codex-owner-isolation.mjs +5 -3
  12. package/shared/codex-read-only-room-tools.d.mts +2 -0
  13. package/shared/codex-read-only-room-tools.mjs +24 -0
  14. package/shared/mcp-workspace-capture-worker.mjs +2 -1
  15. package/shared/message-link-preview-store.d.mts +10 -0
  16. package/shared/message-link-preview-store.mjs +77 -0
  17. package/shared/message-link-previews.d.mts +17 -0
  18. package/shared/message-link-previews.mjs +76 -0
  19. package/shared/message-pin-store.d.mts +20 -0
  20. package/shared/message-pin-store.mjs +102 -0
  21. package/shared/message-pins.d.mts +25 -0
  22. package/shared/message-pins.mjs +16 -0
  23. package/shared/message-reaction-store.d.mts +51 -0
  24. package/shared/message-reaction-store.mjs +290 -0
  25. package/shared/message-reactions.d.mts +73 -0
  26. package/shared/message-reactions.mjs +130 -0
  27. package/shared/message-search-controller.d.mts +38 -0
  28. package/shared/message-search-controller.mjs +99 -0
  29. package/shared/message-search.d.mts +46 -0
  30. package/shared/message-search.mjs +114 -0
  31. package/shared/room-message-urls.d.mts +25 -0
  32. package/shared/room-message-urls.mjs +119 -0
  33. package/shared/room-notification-client.d.mts +18 -0
  34. package/shared/room-notification-client.mjs +140 -0
  35. package/shared/room-notification-preferences.d.mts +17 -0
  36. package/shared/room-notification-preferences.mjs +34 -0
  37. package/shared/room-resource-invalidation.d.mts +5 -0
  38. package/shared/room-resource-invalidation.mjs +6 -0
  39. package/shared/room-settings.d.mts +16 -0
  40. package/shared/room-settings.mjs +14 -0
  41. package/shared/room-typing.d.mts +18 -0
  42. package/shared/room-typing.mjs +129 -0
  43. package/shared/room-unread-client.ts +128 -0
  44. package/shared/room-unread.d.mts +25 -0
  45. package/shared/room-unread.mjs +57 -0
  46. package/shared/room-unread.test.mjs +91 -0
  47. package/shared/rooms/RoomsDirectory.vue +1 -1
  48. package/shared/rooms/directory.ts +1 -0
  49. package/shared/slash-commands.d.mts +31 -0
  50. package/shared/slash-commands.mjs +48 -0
  51. package/shared/slash-commands.test.mjs +26 -0
  52. package/shared/ui/GitHubEventCard.vue +229 -0
  53. package/shared/ui/MessageReactionBar.vue +65 -0
  54. package/shared/ui/MessageReactionPicker.vue +291 -0
  55. package/shared/ui/MessageSearchResults.vue +106 -0
  56. package/shared/ui/PinnedMessages.vue +129 -0
  57. package/shared/ui/TypingIndicator.vue +134 -0
  58. package/shared/ui/message-reactions.css +254 -0
  59. package/shared/ui/message-search.css +186 -0
  60. package/shared/ui/room-agent-work.css +178 -0
  61. package/shared/ui/room-message-motion.ts +143 -0
  62. package/shared/ui/useComposerSlashCommands.ts +92 -0
  63. package/shared/ui/usePinnedMessageVisibility.ts +20 -0
  64. package/shared/ui/useRoomMessageMotion.ts +195 -0
  65. package/shared/ui/useSlidingTabIndicator.ts +72 -0
  66. package/shared/workspace-change-capture.mjs +3 -1
  67. package/shared/workspace-turn-capture.d.mts +4 -1
  68. package/shared/workspace-turn-capture.mjs +203 -6
@@ -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,129 @@
1
+ <template>
2
+ <nav v-if="pins.length && !pinnedMessagesHidden" ref="rail" class="message-pin-rail" aria-label="Pinned messages"
3
+ :data-expanded="expanded" @keydown="onKeydown" @pointerenter="onPointerEnter" @pointerleave="scheduleClose"
4
+ @focusin="openPanel" @focusout="onFocusOut">
5
+ <button v-for="(pin, index) in pins" :key="pin.message_id" type="button" class="message-pin-tick"
6
+ data-pin-entry :data-pinned-message-id="pin.message_id" :tabindex="index === active ? 0 : -1"
7
+ :aria-label="`Pinned message from ${senderLabel(pin.sender)}: ${previewText(pin.snippet)}`"
8
+ :aria-current="selectedId === pin.message_id ? 'location' : undefined"
9
+ :data-active="selectedId === pin.message_id" @focus="active = index" @click="choose(pin.message_id)">
10
+ <span class="message-pin-bar" aria-hidden="true" />
11
+ <span v-if="expanded" class="message-pin-title" aria-hidden="true">{{ previewText(pin.snippet) }}</span>
12
+ </button>
13
+ <p v-if="expanded && loading" class="message-pin-status" role="status">Updating pins…</p>
14
+ <p v-if="expanded && error" class="message-pin-status" role="alert">{{ error }}</p>
15
+ <button v-if="error" type="button" class="message-pin-retry" :aria-label="`${error} Retry loading pinned messages`"
16
+ @click="$emit('refresh')">Retry</button>
17
+ </nav>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { nextTick, onBeforeUnmount, ref, watch } from "vue";
22
+ import type { MessagePin } from "../message-pins.mjs";
23
+ import { usePinnedMessageVisibility } from "./usePinnedMessageVisibility";
24
+
25
+ const props = defineProps<{ pins: readonly MessagePin[]; loading?: boolean; error?: string | null }>();
26
+ const emit = defineEmits<{ reveal: [messageId: string]; refresh: [] }>();
27
+ const { pinnedMessagesHidden } = usePinnedMessageVisibility();
28
+ const rail = ref<HTMLElement | null>(null);
29
+ const active = ref(0);
30
+ const selectedId = ref<string | null>(null);
31
+ const expanded = ref(false);
32
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
33
+
34
+ function cancelClose(): void { clearTimeout(closeTimer); }
35
+ function openPanel(): void { cancelClose(); expanded.value = true; }
36
+ function closePanel(): void { cancelClose(); expanded.value = false; }
37
+ function onPointerEnter(event: PointerEvent): void {
38
+ cancelClose();
39
+ if (event.pointerType === "mouse" && window.matchMedia("(hover: hover) and (pointer: fine)").matches) openPanel();
40
+ }
41
+ function onFocusOut(event: FocusEvent): void {
42
+ if (!rail.value?.contains(event.relatedTarget as Node | null)) closePanel();
43
+ }
44
+ function scheduleClose(): void {
45
+ cancelClose();
46
+ closeTimer = setTimeout(() => {
47
+ if (!rail.value?.contains(document.activeElement)) closePanel();
48
+ }, 100);
49
+ }
50
+ function dismissOnEscape(event: KeyboardEvent): void {
51
+ if (event.key !== "Escape") return;
52
+ event.preventDefault();
53
+ event.stopPropagation();
54
+ closePanel();
55
+ }
56
+ function onOutsidePress(event: PointerEvent): void {
57
+ if (!rail.value?.contains(event.target as Node | null)) closePanel();
58
+ }
59
+ function choose(id: string): void {
60
+ selectedId.value = id;
61
+ closePanel();
62
+ emit("reveal", id);
63
+ }
64
+ function onKeydown(event: KeyboardEvent): void {
65
+ if (event.key === "Escape") { dismissOnEscape(event); return; }
66
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) || !props.pins.length) return;
67
+ event.preventDefault();
68
+ active.value = event.key === "Home" ? 0 : event.key === "End" ? props.pins.length - 1
69
+ : (active.value + (event.key === "ArrowDown" ? 1 : -1) + props.pins.length) % props.pins.length;
70
+ rail.value?.querySelectorAll<HTMLButtonElement>("[data-pin-entry]")[active.value]?.focus();
71
+ }
72
+ function senderLabel(value: string): string { return value.split("|")[0].trim(); }
73
+ function previewText(value: string): string {
74
+ return value.replace(/!?\[([^\]]*)\]\([^)]*(?:\)|$)/g, "$1")
75
+ .replace(/^\s*(?:#{1,6}\s+|>\s*|[-+*]\s+|\d+[.)]\s+)/, "")
76
+ .replace(/\*\*|__|~~|`+/g, "").replace(/\s+/g, " ").trim() || "Message without text";
77
+ }
78
+ watch(expanded, (open) => {
79
+ if (open) {
80
+ document.addEventListener("keydown", dismissOnEscape, true);
81
+ document.addEventListener("pointerdown", onOutsidePress, true);
82
+ } else {
83
+ document.removeEventListener("keydown", dismissOnEscape, true);
84
+ document.removeEventListener("pointerdown", onOutsidePress, true);
85
+ }
86
+ });
87
+ watch([() => props.pins.map(pin => pin.message_id), pinnedMessagesHidden], ([ids, hidden], [previousIds]) => {
88
+ const focusWasInside = rail.value?.contains(document.activeElement);
89
+ const region = rail.value?.parentElement;
90
+ if (hidden || !ids.length) closePanel();
91
+ if (!ids.includes(selectedId.value || "")) selectedId.value = null;
92
+ const retainedIndex = ids.indexOf(previousIds[active.value]);
93
+ active.value = retainedIndex >= 0 ? retainedIndex : Math.min(active.value, Math.max(0, ids.length - 1));
94
+ if (focusWasInside) {
95
+ void nextTick(() => {
96
+ if (!hidden && ids.length) rail.value?.querySelectorAll<HTMLButtonElement>("[data-pin-entry]")[active.value]?.focus();
97
+ else if (region) { region.tabIndex = -1; region.focus({ preventScroll: true }); }
98
+ });
99
+ }
100
+ });
101
+ onBeforeUnmount(() => {
102
+ cancelClose();
103
+ document.removeEventListener("keydown", dismissOnEscape, true);
104
+ document.removeEventListener("pointerdown", onOutsidePress, true);
105
+ });
106
+ </script>
107
+
108
+ <style>
109
+ .message-pin-rail { position: absolute; grid-area: 1 / 1 / 2 / 2; top: 8px; left: 4px; z-index: 4; display: flex; flex-direction: column; box-sizing: border-box; width: 36px; max-height: calc(100% - 16px); padding: 4px; border: 1px solid transparent; border-radius: 10px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none; }
110
+ .message-pin-rail::-webkit-scrollbar { display: none; }
111
+ .message-pin-rail[data-expanded="true"] { width: min(420px, calc(100% - 8px)); border-color: var(--border-strong); background: var(--bg-elevated); box-shadow: var(--shadow-lg); }
112
+ .message-pin-tick { display: flex; align-items: center; gap: 10px; flex: 0 0 28px; width: 100%; height: 28px; min-width: 0; padding: 0 3px; border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary); font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
113
+ .message-pin-bar { display: flex; align-items: center; flex: 0 0 20px; }
114
+ .message-pin-bar::before { content: ""; width: 12px; height: 2px; border-radius: 2px; background: currentColor; opacity: .55; }
115
+ .message-pin-tick[data-active="true"] .message-pin-bar::before, .message-pin-tick:focus-visible .message-pin-bar::before { width: 20px; opacity: 1; }
116
+ .message-pin-title { min-width: 0; padding-right: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
117
+ .message-pin-tick:focus-visible, .message-pin-retry:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
118
+ .message-pin-rail[data-expanded="true"] .message-pin-tick:focus-visible { background: var(--accent-hover); }
119
+ .message-pin-tick:active { background: var(--accent-active); }
120
+ .message-pin-retry { flex: 0 0 auto; border: 0; padding: 8px 0; border-radius: 4px; background: var(--bg-elevated); color: var(--text); font: inherit; font-size: 10px; cursor: pointer; }
121
+ .message-pin-status { flex: 0 0 auto; margin: 8px; color: var(--text-secondary); font-size: 11px; overflow-wrap: anywhere; }
122
+ @media (hover: hover) and (pointer: fine) {
123
+ .message-pin-rail[data-expanded="true"] .message-pin-tick:hover { background: var(--accent-hover); }
124
+ .message-pin-tick:hover .message-pin-bar::before { width: 20px; opacity: 1; }
125
+ }
126
+ @media (pointer: coarse) { .message-pin-tick { flex-basis: 44px; height: 44px; } }
127
+ .message-pin-marker { display: inline-flex; vertical-align: middle; color: var(--text-secondary); margin-inline: 4px; }
128
+ .message-pin-marker svg { display: block; width: 13px; height: 13px; flex: 0 0 auto; }
129
+ </style>
@@ -0,0 +1,134 @@
1
+ <template>
2
+ <div class="room-typing-fold" :class="{ 'is-in': open }" data-testid="room-typing-indicator">
3
+ <span class="room-typing-status" role="status" aria-live="polite" aria-atomic="true">{{ label }}</span>
4
+ <div class="room-typing-clip" aria-hidden="true">
5
+ <div class="room-typing-pad"><div class="room-typing-row">
6
+ <span class="room-typing-marks" :data-count="marks.length">
7
+ <i v-for="(name, index) in marks" :key="index" :style="{ '--room-typing-color': colorFor?.(name) }"></i>
8
+ </span>
9
+ <span class="room-typing-copy">
10
+ <span :key="sentenceKey">
11
+ <template v-for="(part, index) in sentence" :key="index">
12
+ <strong v-if="part.name">{{ part.text }}</strong>
13
+ <template v-else>{{ part.text }}</template>
14
+ </template>
15
+ </span>
16
+ </span>
17
+ <span class="room-typing-wave">
18
+ <i v-for="index in 3" :key="index" :style="{ '--room-typing-color': waveColor(index - 1) }"></i>
19
+ </span>
20
+ </div></div>
21
+ </div>
22
+ </div>
23
+ </template>
24
+ <script setup lang="ts">
25
+ import { computed, ref, watch } from 'vue';
26
+ import { typingLabel, typingSentence } from '../room-typing.mjs';
27
+
28
+ const props = defineProps<{
29
+ /** People typing now, one per account. Names are rendered as text, never as HTML. */
30
+ names: readonly string[];
31
+ /** The room's own sender colour, so a typer's dot matches the dot on their messages. */
32
+ colorFor?: (name: string) => string | undefined;
33
+ }>();
34
+
35
+ const MAX_MARKS = 3;
36
+ const open = computed(() => props.names.length > 0);
37
+ const label = computed(() => typingLabel(props.names));
38
+ // Keep the last sentence while the row leaves, so the exit does not show an empty row.
39
+ const shown = ref<readonly string[]>(props.names);
40
+ watch(() => props.names, names => { if (names.length) shown.value = names; });
41
+ const marks = computed(() => shown.value.slice(0, MAX_MARKS));
42
+ const sentenceKey = computed(() => shown.value.join('\n'));
43
+ const sentence = computed(() => typingSentence(shown.value));
44
+ function waveColor(index: number): string | undefined {
45
+ const typers = marks.value;
46
+ return typers.length ? props.colorFor?.(typers[index % typers.length]) : undefined;
47
+ }
48
+ </script>
49
+ <style scoped>
50
+ /* One row, last in the room's live strip. Its metrics mirror .room-local-agent-work
51
+ so a person typing lines up with the agents working above it. */
52
+ .room-typing-fold {
53
+ position: relative;
54
+ display: grid;
55
+ grid-template-rows: 0fr;
56
+ align-self: center;
57
+ width: min(var(--room-chat-content-max, 720px), 100%);
58
+ transition: grid-template-rows 140ms cubic-bezier(.22, 1, .36, 1), margin-top 140ms cubic-bezier(.22, 1, .36, 1);
59
+ }
60
+ .room-typing-fold.is-in { grid-template-rows: 1fr; transition-duration: 180ms; }
61
+ .room-typing-clip { min-height: 0; overflow: hidden; }
62
+ .room-typing-pad { padding-bottom: 8px; }
63
+ .room-typing-fold:first-child .room-typing-pad { padding-top: 6px; }
64
+ /* Under working agents, sit one row gap below them instead of a full list margin. */
65
+ .room-local-agent-work-list + .room-typing-fold.is-in { margin-top: -4px; }
66
+ .room-typing-status {
67
+ position: absolute;
68
+ width: 1px;
69
+ height: 1px;
70
+ overflow: hidden;
71
+ clip-path: inset(50%);
72
+ white-space: nowrap;
73
+ }
74
+ .room-typing-row {
75
+ display: grid;
76
+ grid-template-columns: 9px minmax(0, 1fr) auto;
77
+ gap: 8px;
78
+ align-items: center;
79
+ width: fit-content;
80
+ max-width: 100%;
81
+ padding: 5px 8px 5px 3px;
82
+ color: var(--text-tertiary, var(--muted));
83
+ opacity: 0;
84
+ transform: translateY(4px);
85
+ transition: opacity 140ms ease, transform 140ms cubic-bezier(.22, 1, .36, 1);
86
+ pointer-events: none;
87
+ }
88
+ .room-typing-fold.is-in .room-typing-row { opacity: 1; transform: none; transition-duration: 180ms; }
89
+ .room-typing-marks { display: flex; width: 9px; }
90
+ .room-typing-marks[data-count="3"] { width: 12px; margin-left: -3px; }
91
+ .room-typing-marks i {
92
+ flex: none;
93
+ width: 6px;
94
+ height: 6px;
95
+ border-radius: 999px;
96
+ background: var(--room-typing-color, currentColor);
97
+ box-shadow: 0 0 0 1.5px var(--bg, transparent);
98
+ }
99
+ .room-typing-marks i + i { margin-left: -3px; }
100
+ .room-typing-copy {
101
+ min-width: 0;
102
+ overflow: hidden;
103
+ font-size: 0.73rem;
104
+ font-weight: 640;
105
+ text-overflow: ellipsis;
106
+ white-space: nowrap;
107
+ }
108
+ .room-typing-copy > span { animation: room-typing-swap 120ms ease; }
109
+ .room-typing-copy strong { color: var(--text-secondary, inherit); font-size: 0.74rem; font-weight: 760; }
110
+ .room-typing-wave { display: inline-flex; gap: 3px; align-items: center; }
111
+ .room-typing-wave i {
112
+ width: 3px;
113
+ height: 3px;
114
+ border-radius: 999px;
115
+ background: var(--room-typing-color, currentColor);
116
+ opacity: 0.35;
117
+ animation: room-typing-wave 1.25s ease-in-out infinite;
118
+ }
119
+ .room-typing-wave i:nth-child(2) { animation-delay: 140ms; }
120
+ .room-typing-wave i:nth-child(3) { animation-delay: 280ms; }
121
+ /* A closed row costs nothing: the wave only runs while someone is typing. */
122
+ .room-typing-fold:not(.is-in) .room-typing-wave i { animation-play-state: paused; }
123
+ @keyframes room-typing-wave {
124
+ 0%, 48%, 100% { opacity: 0.35; transform: translateY(0); }
125
+ 24% { opacity: 1; transform: translateY(-2.5px); }
126
+ }
127
+ @keyframes room-typing-swap { from { opacity: 0; } }
128
+ @media (prefers-reduced-motion: reduce) {
129
+ .room-typing-fold, .room-typing-fold.is-in { transition: none; }
130
+ .room-typing-row, .room-typing-fold.is-in .room-typing-row { transform: none; transition: opacity 140ms ease; }
131
+ .room-typing-wave i { animation: none; opacity: 0.55; }
132
+ .room-typing-copy > span { animation: none; }
133
+ }
134
+ </style>
@@ -0,0 +1,254 @@
1
+ /* Emoji reactions under a message, and the picker that adds one. Shared by the
2
+ desktop and web apps; every colour comes from tokens both apps define. */
3
+
4
+ .message-reactions {
5
+ display: flex;
6
+ flex-wrap: wrap;
7
+ align-items: center;
8
+ gap: 4px;
9
+ margin-top: 6px;
10
+ }
11
+
12
+ .message-reaction {
13
+ display: inline-flex;
14
+ align-items: center;
15
+ gap: 5px;
16
+ box-sizing: border-box;
17
+ height: 24px;
18
+ min-height: 0;
19
+ margin: 0;
20
+ padding: 0 8px;
21
+ border: 1px solid var(--border);
22
+ border-radius: 999px;
23
+ background: var(--bg-subtle);
24
+ color: var(--text-secondary);
25
+ font: inherit;
26
+ font-size: 12px;
27
+ line-height: 1;
28
+ white-space: nowrap;
29
+ -webkit-user-select: none;
30
+ user-select: none;
31
+ }
32
+
33
+ button.message-reaction {
34
+ appearance: none;
35
+ cursor: pointer;
36
+ transition:
37
+ background-color 120ms ease,
38
+ border-color 120ms ease,
39
+ color 120ms ease,
40
+ transform 120ms var(--ease-out, ease-out);
41
+ }
42
+
43
+ button.message-reaction:active {
44
+ transform: scale(0.96);
45
+ }
46
+
47
+ button.message-reaction:focus-visible {
48
+ outline: 2px solid var(--blue);
49
+ outline-offset: 1px;
50
+ }
51
+
52
+ .message-reaction[aria-pressed="true"] {
53
+ border-color: color-mix(in srgb, var(--blue) 55%, transparent);
54
+ background: color-mix(in srgb, var(--blue) 14%, transparent);
55
+ color: var(--text);
56
+ }
57
+
58
+ .message-reaction-emoji {
59
+ font-size: 14px;
60
+ /* Emoji glyphs sit low in most system fonts; this centres them on the count. */
61
+ line-height: 1;
62
+ transform: translateY(-0.5px);
63
+ }
64
+
65
+ .message-reaction-count {
66
+ font-variant-numeric: tabular-nums;
67
+ font-weight: 500;
68
+ }
69
+
70
+ .message-reaction-add {
71
+ padding: 0 7px;
72
+ color: var(--text-tertiary);
73
+ }
74
+
75
+ .message-reaction-add svg {
76
+ display: block;
77
+ width: 14px;
78
+ height: 14px;
79
+ }
80
+
81
+ @media (hover: hover) and (pointer: fine) {
82
+ button.message-reaction:hover {
83
+ border-color: var(--border-strong);
84
+ color: var(--text);
85
+ }
86
+
87
+ button.message-reaction[aria-pressed="true"]:hover {
88
+ border-color: color-mix(in srgb, var(--blue) 75%, transparent);
89
+ background: color-mix(in srgb, var(--blue) 20%, transparent);
90
+ }
91
+ }
92
+
93
+ @media (pointer: coarse) {
94
+ .message-reaction {
95
+ height: 32px;
96
+ padding: 0 11px;
97
+ }
98
+ }
99
+
100
+ .message-reaction-picker {
101
+ position: fixed;
102
+ z-index: 1000;
103
+ box-sizing: border-box;
104
+ width: 284px;
105
+ max-width: calc(100vw - 16px);
106
+ padding: 8px;
107
+ border: 1px solid var(--border-strong);
108
+ border-radius: 12px;
109
+ background: var(--bg-elevated);
110
+ box-shadow: var(--shadow-lg);
111
+ color: var(--text);
112
+ font-family: var(--font-sans, inherit);
113
+ opacity: 1;
114
+ transform: scale(1);
115
+ transition:
116
+ opacity 140ms var(--ease-out, ease-out),
117
+ transform 140ms var(--ease-out, ease-out);
118
+ }
119
+
120
+ /* The picker grows out of the control that opened it. */
121
+ .message-reaction-picker[data-side="above"][data-align="start"] { transform-origin: bottom left; }
122
+ .message-reaction-picker[data-side="above"][data-align="end"] { transform-origin: bottom right; }
123
+ .message-reaction-picker[data-side="below"][data-align="start"] { transform-origin: top left; }
124
+ .message-reaction-picker[data-side="below"][data-align="end"] { transform-origin: top right; }
125
+
126
+ @starting-style {
127
+ .message-reaction-picker {
128
+ opacity: 0;
129
+ transform: scale(0.96);
130
+ }
131
+ }
132
+
133
+ /* Opened from the keyboard, or not yet measured: no motion. */
134
+ .message-reaction-picker[data-instant="true"],
135
+ .message-reaction-picker[data-placed="false"] {
136
+ transition: none;
137
+ }
138
+
139
+ .message-reaction-picker[data-placed="false"] {
140
+ visibility: hidden;
141
+ }
142
+
143
+ .message-reaction-picker-grid {
144
+ display: grid;
145
+ grid-template-columns: repeat(8, 1fr);
146
+ gap: 2px;
147
+ }
148
+
149
+ .message-reaction-picker-emoji {
150
+ appearance: none;
151
+ display: grid;
152
+ place-items: center;
153
+ box-sizing: border-box;
154
+ width: 100%;
155
+ min-height: 0;
156
+ aspect-ratio: 1;
157
+ margin: 0;
158
+ padding: 0;
159
+ border: 1px solid transparent;
160
+ border-radius: 8px;
161
+ background: transparent;
162
+ font: inherit;
163
+ font-size: 18px;
164
+ line-height: 1;
165
+ cursor: pointer;
166
+ transition: background-color 100ms ease, transform 120ms var(--ease-out, ease-out);
167
+ }
168
+
169
+ .message-reaction-picker-emoji:active {
170
+ transform: scale(0.92);
171
+ }
172
+
173
+ .message-reaction-picker-emoji:focus-visible {
174
+ outline: 2px solid var(--blue);
175
+ outline-offset: -2px;
176
+ }
177
+
178
+ .message-reaction-picker-emoji[aria-pressed="true"] {
179
+ border-color: color-mix(in srgb, var(--blue) 55%, transparent);
180
+ background: color-mix(in srgb, var(--blue) 14%, transparent);
181
+ }
182
+
183
+ @media (hover: hover) and (pointer: fine) {
184
+ .message-reaction-picker-emoji:hover {
185
+ background: var(--bg-subtle);
186
+ }
187
+ }
188
+
189
+ .message-reaction-picker-custom {
190
+ display: flex;
191
+ flex-direction: column;
192
+ gap: 4px;
193
+ margin-top: 8px;
194
+ padding-top: 8px;
195
+ border-top: 1px solid var(--border);
196
+ }
197
+
198
+ .message-reaction-picker-input {
199
+ box-sizing: border-box;
200
+ width: 100%;
201
+ min-height: 0;
202
+ height: 30px;
203
+ margin: 0;
204
+ padding: 0 9px;
205
+ border: 1px solid var(--border);
206
+ border-radius: 8px;
207
+ background: var(--bg);
208
+ color: var(--text);
209
+ font: inherit;
210
+ font-size: 13px;
211
+ }
212
+
213
+ .message-reaction-picker-input::placeholder {
214
+ color: var(--text-tertiary);
215
+ }
216
+
217
+ .message-reaction-picker-input:focus-visible {
218
+ border-color: var(--blue);
219
+ outline: 0;
220
+ }
221
+
222
+ .message-reaction-picker-input[aria-invalid="true"] {
223
+ border-color: var(--red-text);
224
+ }
225
+
226
+ .message-reaction-picker-hint {
227
+ margin: 0;
228
+ color: var(--red-text);
229
+ font-size: 12px;
230
+ line-height: 1.3;
231
+ }
232
+
233
+ @media (prefers-reduced-motion: reduce) {
234
+ button.message-reaction,
235
+ .message-reaction-picker-emoji {
236
+ transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
237
+ }
238
+
239
+ button.message-reaction:active,
240
+ .message-reaction-picker-emoji:active {
241
+ transform: none;
242
+ }
243
+
244
+ .message-reaction-picker {
245
+ transition: opacity 140ms ease;
246
+ transform: none;
247
+ }
248
+
249
+ @starting-style {
250
+ .message-reaction-picker {
251
+ transform: none;
252
+ }
253
+ }
254
+ }