letagents 0.12.35 → 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.
@@ -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,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
+ }
@@ -0,0 +1,195 @@
1
+ import { inject, nextTick, onBeforeUnmount, onDeactivated, onMounted, onScopeDispose, provide, ref, shallowRef, watch, type InjectionKey, type Ref } from 'vue';
2
+ import { appendedMotionMessages, captureWork, createRoomMessageAnimator, sameMotionAgent, type MotionMessage, type MessageMotionIdentity, type WorkGeometry } from './room-message-motion';
3
+
4
+ interface SendOrigin {
5
+ text: string; bounds: DOMRect; keyboard: boolean; messageId?: string;
6
+ }
7
+ function createSendContext() {
8
+ const revision = ref(0);
9
+ let pending: SendOrigin | null = null;
10
+ return {
11
+ revision,
12
+ capture(text: string, input: HTMLTextAreaElement | null) {
13
+ if (!input) return () => {};
14
+ const origin = { text, bounds: input.getBoundingClientRect(), keyboard: document.activeElement === input };
15
+ pending = origin;
16
+ return () => { if (pending === origin) pending = null; };
17
+ },
18
+ confirmation(text: string) {
19
+ const origin = pending?.text === text ? pending : null;
20
+ return (messageId: string) => {
21
+ if (origin && pending === origin) { origin.messageId = messageId; revision.value++; }
22
+ };
23
+ },
24
+ consume(message: MotionMessage) {
25
+ if (!pending || !(pending.messageId ? pending.messageId === message.id : message.outgoing && pending.text === message.text)) return null;
26
+ const result = pending; pending = null; return result;
27
+ },
28
+ peekId() { return pending?.messageId; },
29
+ clear() { pending = null; },
30
+ };
31
+ }
32
+ const sendKey: InjectionKey<ReturnType<typeof createSendContext>> = Symbol('room-message-send-motion');
33
+ export function provideRoomMessageMotion(scope: () => string | null | undefined) {
34
+ const context = createSendContext();
35
+ provide(sendKey, context);
36
+ watch(scope, context.clear, { flush: 'sync' });
37
+ onBeforeUnmount(context.clear);
38
+ onDeactivated(context.clear);
39
+ return context;
40
+ }
41
+ export function injectRoomMessageMotion() { return inject(sendKey, null); }
42
+
43
+ /** Keep the visible origin across independently delivered work/message updates.
44
+ * One second covers a brief stream handoff, without leaving no-reply turns working.
45
+ * This is presentation only: it never changes agent state or room messages.
46
+ */
47
+ export function useRoomWorkHandoff<T>(options: {
48
+ work: () => readonly T[];
49
+ identity: (work: T) => MessageMotionIdentity;
50
+ after: (work: T) => string | null | undefined;
51
+ messages: () => readonly MotionMessage[];
52
+ scope: () => string | null | undefined;
53
+ enabled: () => boolean;
54
+ }) {
55
+ const work = shallowRef<readonly T[]>([]);
56
+ let pending: { value: T; expires: number }[] = [];
57
+ let previous: readonly T[] = [];
58
+ let timer: ReturnType<typeof setTimeout> | undefined;
59
+ const clear = () => {
60
+ clearTimeout(timer); timer = undefined; pending = [];
61
+ work.value = previous = options.work();
62
+ };
63
+ const sameTurn = (a: T, b: T) => sameMotionAgent(options.identity(a), options.identity(b)) && options.after(a) === options.after(b);
64
+ function update() {
65
+ clearTimeout(timer); timer = undefined;
66
+ const current = options.work(), messages = options.messages(), now = Date.now();
67
+ if (!options.enabled() || (typeof document !== 'undefined' && document.visibilityState === 'hidden')
68
+ || (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)) { clear(); return; }
69
+ const eligible = (value: T) => {
70
+ const after = options.after(value), start = messages.findIndex(message => message.id === after);
71
+ if (!after || start < 0) return false;
72
+ // A matching reply consumes this turn. A new source from the same
73
+ // agent must not inherit a previous turn's presentation.
74
+ return !messages.slice(start + 1).some(message => sameMotionAgent(options.identity(value), message))
75
+ && !current.some(item => sameMotionAgent(options.identity(item), options.identity(value)) && !sameTurn(item, value));
76
+ };
77
+ pending = pending.filter(item => item.expires > now && eligible(item.value) && !current.some(value => sameTurn(value, item.value)));
78
+ for (const value of previous) {
79
+ if (!current.some(item => sameTurn(item, value)) && !pending.some(item => sameTurn(item.value, value)) && eligible(value)) {
80
+ pending.push({ value, expires: now + 1000 });
81
+ }
82
+ }
83
+ previous = current;
84
+ work.value = [...current, ...pending.map(item => item.value)];
85
+ if (pending.length) timer = setTimeout(update, Math.max(0, Math.min(...pending.map(item => item.expires)) - now));
86
+ }
87
+ watch([options.work, options.messages], update, { immediate: true, deep: true });
88
+ watch(options.scope, () => { clear(); work.value = previous = []; }, { flush: 'sync' });
89
+ watch(options.enabled, enabled => { if (!enabled) clear(); }, { flush: 'sync' });
90
+ onScopeDispose(() => { clearTimeout(timer); });
91
+ return { work, clear };
92
+ }
93
+
94
+ /** Lists own scrolling/history; an own send requests latest before measuring its landing. */
95
+ export function useRoomMessageMotion(options: {
96
+ element: Ref<HTMLElement | null>;
97
+ /** Work rows pinned outside the scrolling list. A reply still grows from its row there. */
98
+ work?: Ref<HTMLElement | null>;
99
+ messages: () => MotionMessage[];
100
+ scope: () => string | null | undefined;
101
+ ready: () => boolean;
102
+ following: () => boolean;
103
+ scrollToLatest: () => void;
104
+ onInterrupt?: () => void;
105
+ }) {
106
+ const send = injectRoomMessageMotion();
107
+ const animator = createRoomMessageAnimator(() => options.element.value, () => options.work?.value ?? null);
108
+ let previous = options.messages().map(message => message.stableId);
109
+ let revision = 0;
110
+ let media: MediaQueryList | null = null;
111
+ const cancel = () => { revision++; animator.cancel(); };
112
+ const interrupt = () => { cancel(); options.onInterrupt?.(); };
113
+ watch(options.scope, () => { interrupt(); previous = options.messages().map(message => message.stableId); }, { flush: 'sync' });
114
+ watch(options.ready, ready => { if (!ready) interrupt(); });
115
+ watch([options.messages, () => send?.revision.value], async ([messages]) => {
116
+ const seen = new Set(previous);
117
+ const appended = appendedMotionMessages(previous, messages);
118
+ previous = messages.map(message => message.stableId);
119
+ // A first message in an empty room is allowed only when this composer sent it.
120
+ // A late acknowledgement consumes the origin without replaying an earlier echo.
121
+ const outgoing = messages.find(message => message.id === send?.peekId() || message.outgoing);
122
+ const candidates = [...appended];
123
+ if (outgoing && !candidates.includes(outgoing)) candidates.push(outgoing);
124
+ const sends = new Map<string, SendOrigin>();
125
+ for (const message of candidates) {
126
+ const source = send?.consume(message);
127
+ if (source) sends.set(message.id, source);
128
+ }
129
+ if (!appended.length && !sends.size) return;
130
+ const element = options.element.value;
131
+ const following = options.following();
132
+ // Capture the current presentation before cancelling a previous flight, so
133
+ // rapid arrivals continue from where the visible rows actually are.
134
+ const viewport = element?.getBoundingClientRect();
135
+ const positions = new Map<HTMLElement, DOMRect>();
136
+ if (element && viewport && following && options.ready()) {
137
+ for (const row of element.querySelectorAll<HTMLElement>('[data-message-id], [data-msg-id]')) {
138
+ const bounds = row.getBoundingClientRect();
139
+ if (bounds.bottom > viewport.top && bounds.top < viewport.bottom) positions.set(row, bounds);
140
+ }
141
+ }
142
+ cancel();
143
+ const currentRevision = revision;
144
+ if (!element || !options.ready() || (!following && !sends.size)) return;
145
+ const sources = new Map<string, WorkGeometry>();
146
+ const used = new Set<HTMLElement>();
147
+ const workRows = [...(options.work?.value ?? element).querySelectorAll<HTMLElement>('[data-motion-work]')];
148
+ for (const message of appended) {
149
+ const matches = workRows.filter(work => !used.has(work) && sameMotionAgent(message, { session: work.dataset.motionSession, key: work.dataset.motionAgent })
150
+ && (!work.dataset.motionAfter || (messages.findIndex(item => item.id === work.dataset.motionAfter) >= 0 && messages.findIndex(item => item.id === work.dataset.motionAfter) < messages.indexOf(message))));
151
+ if (matches.length !== 1) continue;
152
+ const source = captureWork(matches[0]);
153
+ if (source) { sources.set(message.id, source); used.add(matches[0]); }
154
+ }
155
+ await nextTick();
156
+ // Scroll followers may themselves be queued by this render (e.g. an API acknowledgement).
157
+ await nextTick();
158
+ if (currentRevision !== revision || !options.ready() || element !== options.element.value) return;
159
+ // This is navigation requested by the sender, including reduced motion and
160
+ // a late web acknowledgement after its stream echo. Never replay that echo.
161
+ if (sends.size) options.scrollToLatest();
162
+ if (media?.matches || document.visibilityState === 'hidden') return;
163
+ animator.move(positions);
164
+ const rows = [...element.querySelectorAll<HTMLElement>('[data-message-id], [data-msg-id]')];
165
+ for (const message of candidates) {
166
+ const row = rows.find(row => (row.dataset.messageId || row.dataset.msgId) === message.id);
167
+ if (!row) continue;
168
+ const bounds = row.getBoundingClientRect(), viewport = element.getBoundingClientRect();
169
+ if (bounds.bottom < viewport.top || bounds.top > viewport.bottom) continue;
170
+ const origin = sends.get(message.id), work = sources.get(message.id);
171
+ if (origin && !seen.has(message.stableId)) animator.send(row, origin.bounds, origin.keyboard);
172
+ else if (work) animator.reply(row, work);
173
+ else if (appended.includes(message)) animator.reveal(row);
174
+ }
175
+ });
176
+ const onKey = (event: KeyboardEvent) => { if (['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown'].includes(event.key)) interrupt(); };
177
+ onMounted(() => {
178
+ media = typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
179
+ media?.addEventListener('change', interrupt);
180
+ window.addEventListener('resize', interrupt);
181
+ options.element.value?.addEventListener('wheel', interrupt, { passive: true });
182
+ options.element.value?.addEventListener('touchstart', interrupt, { passive: true });
183
+ options.element.value?.addEventListener('pointerdown', interrupt, { passive: true });
184
+ options.element.value?.addEventListener('keydown', onKey);
185
+ });
186
+ onDeactivated(interrupt);
187
+ onBeforeUnmount(() => {
188
+ interrupt(); media?.removeEventListener('change', interrupt); window.removeEventListener('resize', interrupt);
189
+ options.element.value?.removeEventListener('wheel', interrupt);
190
+ options.element.value?.removeEventListener('touchstart', interrupt);
191
+ options.element.value?.removeEventListener('pointerdown', interrupt);
192
+ options.element.value?.removeEventListener('keydown', onKey);
193
+ });
194
+ return { cancel: interrupt };
195
+ }
@@ -0,0 +1,72 @@
1
+ import { onBeforeUnmount, onMounted, onUpdated, ref, watch } from "vue";
2
+
3
+ /** One underline, shared by the desktop and web room headers. */
4
+ export function useSlidingTabIndicator(activeTab: () => string) {
5
+ const tabsElement = ref<HTMLElement | null>(null);
6
+ const indicatorElement = ref<HTMLElement | null>(null);
7
+ let animation: Animation | null = null;
8
+ let motion: MediaQueryList | undefined;
9
+ let resizeObserver: ResizeObserver | undefined;
10
+ let animateNextChange = false;
11
+ let placed = false;
12
+
13
+ function position(animate = false): void {
14
+ const tabs = tabsElement.value;
15
+ const indicator = indicatorElement.value;
16
+ const selected = tabs?.querySelector<HTMLElement>('[aria-current="page"]');
17
+ if (!tabs || !indicator || !selected) {
18
+ animation?.cancel();
19
+ if (indicator) indicator.style.visibility = "hidden";
20
+ placed = false;
21
+ return;
22
+ }
23
+ const width = selected.offsetWidth;
24
+ if (!width) return;
25
+ const target = `translate(${selected.offsetLeft}px, ${selected.offsetTop + selected.offsetHeight - 2}px)`;
26
+ // Content updates and ResizeObserver notifications must not restart a running move.
27
+ if (placed && indicator.style.transform === target && indicator.style.width === `${width}px`) return;
28
+ const previous = indicator.getBoundingClientRect();
29
+ const origin = tabs.getBoundingClientRect();
30
+ animation?.cancel();
31
+ animation = null;
32
+ indicator.style.width = `${width}px`;
33
+ indicator.style.transform = target;
34
+ indicator.style.visibility = "visible";
35
+ if (animate && placed && !motion?.matches && indicator.animate) {
36
+ animation = indicator.animate([
37
+ { transform: `translate(${previous.left - origin.left + tabs.scrollLeft}px, ${previous.top - origin.top + tabs.scrollTop}px) scaleX(${previous.width / width})` },
38
+ { transform: `${target} scaleX(1)` },
39
+ ], { duration: 240, easing: "cubic-bezier(.22, 1, .36, 1)" });
40
+ }
41
+ placed = true;
42
+ }
43
+
44
+ function prepareTabChange(event: MouseEvent, tab: string): void {
45
+ animateNextChange = event.detail > 0 && tab !== activeTab();
46
+ }
47
+
48
+ function syncLayout(): void { position(); }
49
+ function syncMotion(): void { animation?.cancel(); animation = null; position(); }
50
+
51
+ watch(activeTab, () => {
52
+ position(animateNextChange);
53
+ animateNextChange = false;
54
+ }, { flush: "post" });
55
+ onUpdated(syncLayout);
56
+ onMounted(() => {
57
+ motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
58
+ motion?.addEventListener("change", syncMotion);
59
+ if (typeof ResizeObserver !== "undefined" && tabsElement.value) {
60
+ resizeObserver = new ResizeObserver(syncLayout);
61
+ resizeObserver.observe(tabsElement.value);
62
+ }
63
+ syncLayout();
64
+ });
65
+ onBeforeUnmount(() => {
66
+ animation?.cancel();
67
+ resizeObserver?.disconnect();
68
+ motion?.removeEventListener("change", syncMotion);
69
+ });
70
+
71
+ return { tabsElement, indicatorElement, prepareTabChange };
72
+ }
@@ -5,10 +5,12 @@ import { join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { WORKSPACE_FILE_LIMIT, WORKSPACE_PATCH_LIMIT, parseWorkspaceChangeSummary } from './workspace-change-summary.mjs';
7
7
  const execute = promisify(execFile);
8
+ // Replace refs are off: with them the workspace could put another tree in the place of the
9
+ // one a comparison starts from, and the comparison would show nothing.
8
10
  async function git(cwd, args, limit = 4 * 1024 * 1024) {
9
11
  const result = await execute('git', ['--no-optional-locks', ...args], {
10
12
  cwd, encoding: 'utf8', timeout: 5_000, maxBuffer: limit,
11
- env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), GIT_TERMINAL_PROMPT: '0' },
13
+ env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1' },
12
14
  });
13
15
  return result.stdout;
14
16
  }
@@ -3,7 +3,10 @@ import type { WorkspaceReview } from './workspace-review.mjs';
3
3
  export function unavailableWorkspace(): WorkspaceChangeSummary;
4
4
  export function captureWorkspaceTree(workspace: string, identity: string): Promise<string | null>;
5
5
  export function releaseWorkspaceTree(workspace: string, identity: string): Promise<void>;
6
- export function captureWorkspacePair(workspace: string, startingRevision: string | null, baseline: string | null, identity: string): Promise<{
6
+ export function captureWorkspaceHead(workspace: string): Promise<string | null>;
7
+ export function captureRemoteTips(workspace: string): Promise<string[] | null>;
8
+ export function captureRecordedRemoteTips(url: string | null | undefined, directory?: string): Promise<string[] | null>;
9
+ export function captureWorkspacePair(workspace: string, startingRevision: string | null, baseline: string | null, identity: string, startingHead?: string | null, remoteTips?: readonly string[] | null | Promise<readonly string[] | null>): Promise<{
7
10
  workspace: WorkspaceChangeSummary;
8
11
  contribution: { changes: WorkspaceChangeSummary; summary: string | null };
9
12
  review: WorkspaceReview;