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,186 @@
1
+ /* Matches from a room's earlier history, listed under the find control.
2
+ Shared by the desktop and web apps; colours come from tokens both define. */
3
+
4
+ .history-search {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 6px;
8
+ min-height: 0;
9
+ color: var(--text);
10
+ font-size: 13px;
11
+ line-height: 1.45;
12
+ }
13
+
14
+ .history-search-note {
15
+ margin: 0;
16
+ padding: 2px 2px 4px;
17
+ color: var(--text-secondary);
18
+ }
19
+
20
+ .history-search-error {
21
+ color: var(--red-text);
22
+ }
23
+
24
+ .history-search-heading {
25
+ display: flex;
26
+ align-items: baseline;
27
+ gap: 8px;
28
+ margin: 0;
29
+ padding: 0 2px;
30
+ color: var(--text-secondary);
31
+ font-size: 12px;
32
+ font-weight: 600;
33
+ letter-spacing: 0.01em;
34
+ }
35
+
36
+ .history-search-count {
37
+ color: var(--text-tertiary);
38
+ font-variant-numeric: tabular-nums;
39
+ font-weight: 500;
40
+ }
41
+
42
+ .history-search-list {
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: 2px;
46
+ min-height: 0;
47
+ margin: 0;
48
+ padding: 0;
49
+ overflow-y: auto;
50
+ list-style: none;
51
+ }
52
+
53
+ .history-search-hit {
54
+ display: grid;
55
+ grid-template-columns: minmax(0, 1fr) auto;
56
+ align-items: start;
57
+ gap: 4px 8px;
58
+ padding: 6px 8px;
59
+ border-radius: 8px;
60
+ }
61
+
62
+ .history-search-hit-toggle {
63
+ appearance: none;
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: 2px;
67
+ min-width: 0;
68
+ min-height: 0;
69
+ margin: 0;
70
+ padding: 0;
71
+ border: 0;
72
+ background: transparent;
73
+ color: inherit;
74
+ font: inherit;
75
+ text-align: left;
76
+ cursor: pointer;
77
+ }
78
+
79
+ .history-search-hit-meta {
80
+ display: flex;
81
+ align-items: baseline;
82
+ gap: 8px;
83
+ min-width: 0;
84
+ }
85
+
86
+ .history-search-hit-meta strong {
87
+ overflow: hidden;
88
+ font-weight: 600;
89
+ text-overflow: ellipsis;
90
+ white-space: nowrap;
91
+ }
92
+
93
+ .history-search-hit-meta time {
94
+ flex-shrink: 0;
95
+ color: var(--text-tertiary);
96
+ font-size: 12px;
97
+ font-variant-numeric: tabular-nums;
98
+ }
99
+
100
+ .history-search-hit-text {
101
+ display: -webkit-box;
102
+ overflow: hidden;
103
+ color: var(--text-secondary);
104
+ overflow-wrap: anywhere;
105
+ -webkit-box-orient: vertical;
106
+ -webkit-line-clamp: 2;
107
+ line-clamp: 2;
108
+ }
109
+
110
+ .history-search-hit[data-expanded="true"] .history-search-hit-text {
111
+ display: block;
112
+ max-height: 40vh;
113
+ overflow-y: auto;
114
+ color: var(--text);
115
+ white-space: pre-wrap;
116
+ }
117
+
118
+ .history-search-hit-text mark {
119
+ border-radius: 3px;
120
+ background: color-mix(in srgb, var(--amber) 32%, transparent);
121
+ color: inherit;
122
+ }
123
+
124
+ .history-search-hit-show,
125
+ .history-search-more {
126
+ appearance: none;
127
+ min-height: 0;
128
+ margin: 0;
129
+ padding: 3px 8px;
130
+ border: 1px solid var(--border);
131
+ border-radius: 6px;
132
+ background: transparent;
133
+ color: var(--text-secondary);
134
+ font: inherit;
135
+ font-size: 12px;
136
+ white-space: nowrap;
137
+ cursor: pointer;
138
+ transition: border-color 120ms ease, color 120ms ease, transform 120ms var(--ease-out, ease-out);
139
+ }
140
+
141
+ .history-search-more {
142
+ align-self: flex-start;
143
+ margin: 2px 8px 0;
144
+ }
145
+
146
+ .history-search-more:disabled {
147
+ cursor: default;
148
+ opacity: 0.6;
149
+ }
150
+
151
+ .history-search-hit-show:active,
152
+ .history-search-more:not(:disabled):active {
153
+ transform: scale(0.97);
154
+ }
155
+
156
+ .history-search-hit-toggle:focus-visible,
157
+ .history-search-hit-show:focus-visible,
158
+ .history-search-more:focus-visible {
159
+ outline: 2px solid var(--blue);
160
+ outline-offset: 2px;
161
+ border-radius: 6px;
162
+ }
163
+
164
+ @media (hover: hover) and (pointer: fine) {
165
+ .history-search-hit:hover {
166
+ background: var(--bg-subtle);
167
+ }
168
+
169
+ .history-search-hit-show:hover,
170
+ .history-search-more:not(:disabled):hover {
171
+ border-color: var(--border-strong);
172
+ color: var(--text);
173
+ }
174
+ }
175
+
176
+ @media (prefers-reduced-motion: reduce) {
177
+ .history-search-hit-show,
178
+ .history-search-more {
179
+ transition: border-color 120ms ease, color 120ms ease;
180
+ }
181
+
182
+ .history-search-hit-show:active,
183
+ .history-search-more:not(:disabled):active {
184
+ transform: none;
185
+ }
186
+ }
@@ -0,0 +1,178 @@
1
+ /* Live strip: working agents, then people typing. Pinned between the timeline and the composer. */
2
+ .room-live-strip {
3
+ display: flex;
4
+ flex: none;
5
+ flex-direction: column;
6
+ min-width: 0;
7
+ }
8
+
9
+ .room-local-agent-work-list {
10
+ display: grid;
11
+ gap: 4px;
12
+ align-self: center;
13
+ width: min(var(--room-chat-content-max), 100%);
14
+ margin: 6px 0 8px;
15
+ }
16
+
17
+ .room-local-agent-work {
18
+ display: grid;
19
+ grid-template-columns: 9px minmax(0, 1fr) auto;
20
+ gap: 8px;
21
+ align-items: center;
22
+ width: fit-content;
23
+ max-width: 100%;
24
+ padding: 5px 8px 5px 3px;
25
+ border: 0;
26
+ border-radius: 8px;
27
+ background: transparent;
28
+ color: var(--text-secondary);
29
+ font: inherit;
30
+ text-align: start;
31
+ cursor: pointer;
32
+ box-shadow: none;
33
+ appearance: none;
34
+ }
35
+
36
+ .room-local-agent-work:focus-visible {
37
+ outline: 2px solid var(--blue, #3b82f6);
38
+ outline-offset: 1px;
39
+ }
40
+
41
+ .room-local-agent-work:active {
42
+ background: var(--accent-active);
43
+ }
44
+
45
+ @media (hover: hover) and (pointer: fine) {
46
+ .room-local-agent-work:hover {
47
+ background: var(--accent-hover);
48
+ }
49
+ }
50
+
51
+ .room-local-agent-work-pulse {
52
+ width: 6px;
53
+ height: 6px;
54
+ border-radius: 999px;
55
+ background: #34d399;
56
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.08);
57
+ animation: local-agent-work-pulse 1.8s ease-in-out infinite;
58
+ }
59
+
60
+ /* An agent that waits to try again is not working: its row rests. */
61
+ .room-local-agent-work[data-waiting] .room-local-agent-work-pulse {
62
+ background: var(--amber, #f59e0b);
63
+ box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.08);
64
+ animation: none;
65
+ }
66
+
67
+ .room-local-agent-work[data-waiting] .room-local-agent-work-dots {
68
+ display: none;
69
+ }
70
+
71
+ .room-local-agent-work-copy {
72
+ display: flex;
73
+ min-width: 0;
74
+ align-items: baseline;
75
+ gap: 7px;
76
+ }
77
+
78
+ .room-local-agent-work strong {
79
+ color: var(--text-secondary);
80
+ font-size: 0.74rem;
81
+ font-weight: 760;
82
+ white-space: nowrap;
83
+ }
84
+
85
+ .room-local-agent-work-copy span {
86
+ min-width: 0;
87
+ overflow: hidden;
88
+ color: var(--text-tertiary);
89
+ font-size: 0.73rem;
90
+ font-weight: 640;
91
+ text-overflow: ellipsis;
92
+ white-space: nowrap;
93
+ }
94
+
95
+ .room-local-agent-work-dots {
96
+ display: inline-flex;
97
+ gap: 3px;
98
+ align-items: center;
99
+ }
100
+
101
+ .room-local-agent-work-dots i {
102
+ width: 3px;
103
+ height: 3px;
104
+ border-radius: 999px;
105
+ background: currentColor;
106
+ opacity: 0.32;
107
+ animation: local-agent-work-dot 1.1s ease-in-out infinite;
108
+ }
109
+
110
+ .room-local-agent-work-dots i:nth-child(2) {
111
+ animation-delay: 120ms;
112
+ }
113
+
114
+ .room-local-agent-work-dots i:nth-child(3) {
115
+ animation-delay: 240ms;
116
+ }
117
+
118
+ /* What the owner can do about the wait in the row above. It stands under that row's text. */
119
+ .room-local-agent-work-action {
120
+ justify-self: start;
121
+ margin: 0 0 2px 20px;
122
+ padding: 3px 9px;
123
+ border: 0;
124
+ border-radius: 8px;
125
+ background: var(--accent-hover);
126
+ color: var(--text-secondary);
127
+ font: inherit;
128
+ font-size: 0.72rem;
129
+ font-weight: 640;
130
+ cursor: pointer;
131
+ appearance: none;
132
+ }
133
+
134
+ .room-local-agent-work-action:focus-visible {
135
+ outline: 2px solid var(--blue, #3b82f6);
136
+ outline-offset: 1px;
137
+ }
138
+
139
+ .room-local-agent-work-action:active {
140
+ background: var(--accent-active);
141
+ }
142
+
143
+ .room-local-agent-work-overflow {
144
+ margin: 2px 0 0;
145
+ padding-left: 4px;
146
+ color: var(--text-tertiary);
147
+ font-size: 0.72rem;
148
+ font-weight: 640;
149
+ }
150
+
151
+ @keyframes local-agent-work-pulse {
152
+ 0%,
153
+ 100% {
154
+ opacity: 0.72;
155
+ transform: scale(0.88);
156
+ }
157
+ 50% {
158
+ opacity: 1;
159
+ transform: scale(1);
160
+ }
161
+ }
162
+
163
+ @keyframes local-agent-work-dot {
164
+ 0%,
165
+ 100% {
166
+ opacity: 0.25;
167
+ transform: translateY(0);
168
+ }
169
+ 45% {
170
+ opacity: 0.82;
171
+ transform: translateY(-1px);
172
+ }
173
+ }
174
+
175
+
176
+ @media (prefers-reduced-motion: reduce) {
177
+ .room-local-agent-work-pulse, .room-local-agent-work-dots i { animation: none; }
178
+ }
@@ -0,0 +1,143 @@
1
+ /** Geometry and WAAPI effects shared by the real desktop and web timelines. */
2
+ export interface MessageMotionIdentity { session?: string | null; key?: string | null }
3
+ export interface MotionMessage extends MessageMotionIdentity {
4
+ id: string; stableId: string; text: string; outgoing?: boolean;
5
+ }
6
+ export function sameMotionAgent(a: MessageMotionIdentity, b: MessageMotionIdentity): boolean {
7
+ if (a.session && b.session) return a.session === b.session;
8
+ return Boolean(a.key && b.key && a.key.trim().toLowerCase() === b.key.trim().toLowerCase());
9
+ }
10
+ export function appendedMotionMessages(previous: readonly string[], messages: readonly MotionMessage[]): MotionMessage[] {
11
+ if (!previous.length) return [];
12
+ const last = messages.findIndex(message => message.stableId === previous.at(-1));
13
+ if (last < 0) return [];
14
+ const seen = new Set(previous);
15
+ return messages.slice(last + 1).filter(message => !seen.has(message.stableId));
16
+ }
17
+ const bubbleSelector = '.room-message-bubble, .message-bubble';
18
+ const metaSelector = '.room-message-meta, .message-meta';
19
+ const avatarSelector = '.room-chat-avatar, .message-avatar';
20
+ const ease = 'cubic-bezier(.22,1,.36,1)';
21
+ export interface WorkGeometry {
22
+ bounds: DOMRect; pulse: DOMRect; name: DOMRect; summary: DOMRect;
23
+ clone: HTMLElement; dots: Keyframe[];
24
+ }
25
+ export function captureWork(element: HTMLElement): WorkGeometry | null {
26
+ const pulse = element.querySelector<HTMLElement>('.room-local-agent-work-pulse');
27
+ const name = element.querySelector<HTMLElement>('strong');
28
+ const summary = element.querySelector<HTMLElement>('.room-local-agent-work-copy > span');
29
+ if (!pulse || !name || !summary) return null;
30
+ return {
31
+ bounds: element.getBoundingClientRect(), pulse: pulse.getBoundingClientRect(),
32
+ name: name.getBoundingClientRect(), summary: summary.getBoundingClientRect(),
33
+ clone: element.cloneNode(true) as HTMLElement,
34
+ dots: [...element.querySelectorAll('i')].map(dot => {
35
+ const style = getComputedStyle(dot); return { opacity: style.opacity, transform: style.transform };
36
+ }),
37
+ };
38
+ }
39
+ /** `work` is where work rows live when they are pinned outside the scrolling `viewport`. */
40
+ export function createRoomMessageAnimator(viewport: () => HTMLElement | null, work: () => HTMLElement | null = () => null) {
41
+ const animations = new Set<Animation>();
42
+ const cleanups = new Set<() => void>();
43
+ function animate(element: Element | null, frames: Keyframe[], options: KeyframeAnimationOptions) {
44
+ if (!element || typeof element.animate !== 'function') return null;
45
+ const animation = element.animate(frames, options);
46
+ animations.add(animation);
47
+ animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
48
+ return animation;
49
+ }
50
+ function cancel() {
51
+ animations.forEach(animation => animation.cancel()); animations.clear();
52
+ cleanups.forEach(cleanup => cleanup()); cleanups.clear();
53
+ }
54
+ function layer() {
55
+ const element = document.createElement('div');
56
+ element.className = 'room-message-motion-layer';
57
+ element.setAttribute('aria-hidden', 'true'); element.inert = true;
58
+ Object.assign(element.style, { position: 'fixed', inset: '0', pointerEvents: 'none', zIndex: '50', overflow: 'hidden' });
59
+ document.body.append(element);
60
+ const cleanup = () => { element.remove(); cleanups.delete(cleanup); };
61
+ cleanups.add(cleanup);
62
+ return { element, cleanup };
63
+ }
64
+ function reveal(row: HTMLElement) {
65
+ animate(row, [{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: ease });
66
+ }
67
+ function move(positions: Map<HTMLElement, DOMRect>) {
68
+ positions.forEach((before, element) => {
69
+ if (!element.isConnected) return;
70
+ const delta = before.top - element.getBoundingClientRect().top;
71
+ if (Math.abs(delta) > 1) animate(element, [{ transform: `translateY(${delta}px)` }, { transform: 'none' }], { duration: 260, easing: ease });
72
+ });
73
+ }
74
+ function send(row: HTMLElement, source: DOMRect, keyboard: boolean) {
75
+ const bubble = row.querySelector<HTMLElement>(bubbleSelector), stream = viewport();
76
+ if (!bubble || !stream) return;
77
+ const target = bubble.getBoundingClientRect();
78
+ if (target.height > stream.clientHeight * .65) { reveal(row); return; }
79
+ const duration = keyboard ? 280 : 360;
80
+ const overlay = layer();
81
+ const clone = bubble.cloneNode(true) as HTMLElement;
82
+ // Copy inherited typography/tokens too: the flight lives outside the room's theme scope.
83
+ const style = getComputedStyle(bubble);
84
+ for (const property of style) clone.style.setProperty(property, style.getPropertyValue(property));
85
+ clone.removeAttribute('id'); clone.querySelectorAll('[id]').forEach(node => node.removeAttribute('id'));
86
+ Object.assign(clone.style, { position: 'absolute', left: `${target.left}px`, top: `${target.top}px`, width: `${target.width}px`, height: `${target.height}px`, maxWidth: 'none', margin: '0', transformOrigin: '0 0', pointerEvents: 'none' });
87
+ overlay.element.append(clone);
88
+ const opacity = bubble.style.opacity; bubble.style.opacity = '0';
89
+ const restore = () => { bubble.style.opacity = opacity; overlay.cleanup(); cleanups.delete(restore); };
90
+ cleanups.add(restore);
91
+ const dx = source.left - target.left - parseFloat(style.paddingLeft);
92
+ const dy = source.top - target.top - parseFloat(style.paddingTop);
93
+ const flight = animate(clone, [
94
+ { transform: `translate(${dx}px,${dy}px) scale(.98)`, opacity: .8, easing: ease },
95
+ { transform: 'none', opacity: 1 },
96
+ ], { duration });
97
+ if (flight) flight.finished.then(restore, restore); else restore();
98
+ for (const selector of [metaSelector, avatarSelector]) animate(row.querySelector(selector), [
99
+ { opacity: 0, transform: 'translateY(5px)' }, { opacity: 1, transform: 'none' },
100
+ ], { duration: duration * .45, delay: duration * .35, fill: 'backwards', easing: ease });
101
+ }
102
+ function reply(row: HTMLElement, source: WorkGeometry) {
103
+ const bubble = row.querySelector<HTMLElement>(bubbleSelector), stream = viewport();
104
+ if (!bubble || !stream) return;
105
+ const target = bubble.getBoundingClientRect(), list = stream.getBoundingClientRect();
106
+ // The flight starts in the pinned work rows and lands in the list, so it is clipped to both.
107
+ const pinned = work()?.getBoundingClientRect();
108
+ const left = Math.min(list.left, pinned?.left ?? list.left), top = Math.min(list.top, pinned?.top ?? list.top);
109
+ const bottom = Math.max(list.bottom, pinned?.bottom ?? list.bottom);
110
+ const bounds = { left, top, bottom, width: Math.max(list.right, pinned?.right ?? list.right) - left, height: bottom - top };
111
+ if (source.bounds.bottom < bounds.top || source.bounds.top > bounds.bottom || target.height > list.height * .8) { reveal(row); return; }
112
+ const duration = 440, style = getComputedStyle(bubble);
113
+ const overlay = layer();
114
+ Object.assign(overlay.element.style, { inset: 'auto', left: `${bounds.left}px`, top: `${bounds.top}px`, width: `${bounds.width}px`, height: `${bounds.height}px` });
115
+ const shell = document.createElement('div');
116
+ Object.assign(shell.style, { position: 'absolute', boxSizing: 'border-box', background: style.background, border: style.border, borderRadius: style.borderRadius, boxShadow: style.boxShadow });
117
+ const ghost = source.clone;
118
+ Object.assign(ghost.style, { position: 'absolute', left: `${source.bounds.left - bounds.left}px`, top: `${source.bounds.top - bounds.top}px`, width: `${source.bounds.width}px`, height: `${source.bounds.height}px`, margin: '0' });
119
+ ghost.querySelectorAll<HTMLElement>('.room-local-agent-work-pulse, strong').forEach(element => { element.style.visibility = 'hidden'; });
120
+ overlay.element.append(shell, ghost);
121
+ const seed = source.summary;
122
+ Object.assign(shell.style, { left: `${target.left - bounds.left}px`, top: `${target.top - bounds.top}px`, width: `${target.width}px`, height: `${target.height}px`, transformOrigin: '0 0' });
123
+ const start = `translate(${seed.left - target.left - 9}px,${seed.top - target.top - 5}px) scale(${(source.bounds.right - seed.left + 9) / target.width},${(seed.height + 10) / target.height})`;
124
+ const surface = animate(shell, [{ transform: start, opacity: .8, easing: ease }, { transform: 'none', opacity: 1, offset: .7 }, { transform: 'none', opacity: 0 }], { duration });
125
+ surface?.finished.then(overlay.cleanup, overlay.cleanup);
126
+ for (const [selector, before] of [[avatarSelector, source.pulse], [`${metaSelector.split(',')[0]} strong, .room-message-author-button, .message-sender strong`, source.name]] as const) {
127
+ const element = row.querySelector(selector);
128
+ if (!element) continue;
129
+ const after = element.getBoundingClientRect();
130
+ animate(element, [{ transform: `translate(${before.left - after.left}px,${before.top - after.top}px)` }, { transform: 'none' }], { duration: duration * .7, easing: ease });
131
+ }
132
+ animate(ghost.querySelector('.room-local-agent-work-copy > span'), [{ opacity: 1 }, { opacity: 0, transform: 'translateX(-8px)' }], { duration: duration * .28, easing: ease, fill: 'forwards' });
133
+ ghost.querySelectorAll<HTMLElement>('i').forEach((dot, index) => {
134
+ dot.style.animation = 'none';
135
+ animate(dot, [source.dots[index] || { opacity: 1 }, { opacity: 1, transform: `translateX(${-index * 6}px) scale(1.3)`, offset: .6 }, { opacity: 0, transform: `translateX(${-index * 6}px) scale(.8)` }], { duration: duration * .38, easing: ease, fill: 'forwards' });
136
+ });
137
+ animate(bubble, [{ opacity: 0 }, { opacity: 0, offset: .2 }, { opacity: 1 }], { duration });
138
+ animate(bubble.querySelector('.desktop-long-message, .long-message-content, .md-content') || bubble.firstElementChild,
139
+ [{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }],
140
+ { duration: duration * .5, delay: duration * .2, fill: 'backwards', easing: ease });
141
+ }
142
+ return { cancel, move, send, reply, reveal };
143
+ }
@@ -0,0 +1,92 @@
1
+ import { computed, onScopeDispose, ref, watch, type Ref } from 'vue';
2
+ import { completeSlashCommand, decideComposerSubmitAction, resolveSlashInput, type SlashCommand, type SlashPlatform } from '../slash-commands.mjs';
3
+
4
+ /** One send gate for keyboard and button submission in both main composers. */
5
+ export function useComposerSlashCommands(options: {
6
+ text: Ref<string>;
7
+ platform: SlashPlatform;
8
+ scope: () => string | null | undefined;
9
+ hasAttachments: () => boolean;
10
+ isReply: () => boolean;
11
+ run: (command: SlashCommand, argument: string) => boolean | Promise<boolean>;
12
+ focus: () => void;
13
+ onError: () => void;
14
+ }) {
15
+ const dismissed = ref(false);
16
+ const activeIndex = ref(0);
17
+ const busy = ref(false);
18
+ let revision = 0;
19
+ onScopeDispose(() => { revision++; });
20
+ watch([() => options.text.value, options.scope, options.hasAttachments, options.isReply], () => {
21
+ revision++;
22
+ dismissed.value = false;
23
+ activeIndex.value = 0;
24
+ }, { flush: 'sync' });
25
+ const context = () => ({
26
+ text: options.text.value,
27
+ platform: options.platform,
28
+ hasAttachments: options.hasAttachments(),
29
+ isReply: options.isReply(),
30
+ menuDismissed: dismissed.value,
31
+ selectedCommandIndex: activeIndex.value,
32
+ });
33
+ const resolution = computed(() => resolveSlashInput(context()));
34
+ const commands = computed(() => {
35
+ const result = resolution.value;
36
+ return result.kind === 'none' ? [] : result.kind === 'menu' ? result.commands : [result.command];
37
+ });
38
+ const open = computed(() => commands.value.length > 0);
39
+ const hint = computed(() => resolution.value.kind === 'invalid' ? resolution.value.hint : '');
40
+ const candidates = computed(() => commands.value.map(command => ({
41
+ key: `slash-${command.name}`, label: command.usage, meta: command.description,
42
+ })));
43
+
44
+ function complete(index = activeIndex.value) {
45
+ const command = commands.value[index];
46
+ if (!command || busy.value) return;
47
+ // Tab/click complete only the name, retaining any argument already entered.
48
+ if (resolution.value.kind === 'menu' || !/\s/.test(options.text.value)) {
49
+ options.text.value = completeSlashCommand(command);
50
+ }
51
+ options.focus();
52
+ }
53
+
54
+ function handleKey(event: KeyboardEvent): boolean {
55
+ if (!open.value || event.isComposing) return false;
56
+ if (event.key === 'Escape') dismissed.value = true;
57
+ else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
58
+ activeIndex.value = (activeIndex.value + (event.key === 'ArrowDown' ? 1 : -1) + commands.value.length) % commands.value.length;
59
+ } else if (event.key === 'Tab') complete();
60
+ else return false;
61
+ event.preventDefault();
62
+ return true;
63
+ }
64
+
65
+ async function submit(sendMessage: () => unknown | Promise<unknown>) {
66
+ if (busy.value) return;
67
+ const decision = decideComposerSubmitAction(context());
68
+ if (decision.action === 'send_message') {
69
+ await sendMessage();
70
+ return;
71
+ }
72
+ if (decision.action === 'complete_command') {
73
+ options.text.value = decision.text;
74
+ options.focus();
75
+ return;
76
+ }
77
+ if (decision.action === 'show_hint') return;
78
+ const submittedRevision = revision;
79
+ busy.value = true;
80
+ try {
81
+ const succeeded = await options.run(decision.command, decision.argument);
82
+ // A late task response must not erase edits or another room's draft.
83
+ if (succeeded && revision === submittedRevision) options.text.value = '';
84
+ } catch {
85
+ options.onError();
86
+ } finally {
87
+ busy.value = false;
88
+ }
89
+ }
90
+
91
+ return { open, candidates, activeIndex, hint, busy, complete, handleKey, submit };
92
+ }
@@ -0,0 +1,20 @@
1
+ import { readonly, ref } from "vue";
2
+
3
+ const storageKey = "letagents:hide-pinned-messages";
4
+
5
+ export function readPinnedMessagesHidden(): boolean {
6
+ try { return window.localStorage.getItem(storageKey) === "true"; }
7
+ catch { return false; }
8
+ }
9
+
10
+ const hidden = ref(readPinnedMessagesHidden());
11
+
12
+ /** A personal display preference; pin membership remains in the room store. */
13
+ export function usePinnedMessageVisibility() {
14
+ function togglePinnedMessages(): void {
15
+ hidden.value = !hidden.value;
16
+ try { window.localStorage.setItem(storageKey, String(hidden.value)); }
17
+ catch { /* Keep the current view usable when local storage is unavailable. */ }
18
+ }
19
+ return { pinnedMessagesHidden: readonly(hidden), togglePinnedMessages };
20
+ }