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.
@@ -1,124 +1,129 @@
1
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 }})
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>
9
12
  </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>
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>
28
18
  </template>
29
19
 
30
20
  <script setup lang="ts">
31
- import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
21
+ import { nextTick, onBeforeUnmount, ref, watch } from "vue";
32
22
  import type { MessagePin } from "../message-pins.mjs";
23
+ import { usePinnedMessageVisibility } from "./usePinnedMessageVisibility";
33
24
 
34
25
  const props = defineProps<{ pins: readonly MessagePin[]; loading?: boolean; error?: string | null }>();
35
26
  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);
27
+ const { pinnedMessagesHidden } = usePinnedMessageVisibility();
28
+ const rail = ref<HTMLElement | null>(null);
39
29
  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));
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();
50
43
  }
51
- function focusEntry(): void {
52
- panel.value?.querySelectorAll<HTMLButtonElement>("[data-pin-entry]")[active.value]?.focus();
44
+ function scheduleClose(): void {
45
+ cancelClose();
46
+ closeTimer = setTimeout(() => {
47
+ if (!rail.value?.contains(document.activeElement)) closePanel();
48
+ }, 100);
53
49
  }
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();
50
+ function dismissOnEscape(event: KeyboardEvent): void {
51
+ if (event.key !== "Escape") return;
52
+ event.preventDefault();
53
+ event.stopPropagation();
54
+ closePanel();
61
55
  }
62
- function close(restoreFocus = true): void {
63
- open.value = false;
64
- if (restoreFocus) trigger.value?.focus();
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);
65
63
  }
66
- function choose(id: string): void { close(); emit("reveal", id); }
67
64
  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;
65
+ if (event.key === "Escape") { dismissOnEscape(event); return; }
66
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) || !props.pins.length) return;
70
67
  event.preventDefault();
71
68
  active.value = event.key === "Home" ? 0 : event.key === "End" ? props.pins.length - 1
72
69
  : (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); }
70
+ rail.value?.querySelectorAll<HTMLButtonElement>("[data-pin-entry]")[active.value]?.focus();
78
71
  }
79
- function timeLabel(value: string): string {
80
- const date = new Date(value);
81
- return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
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";
82
77
  }
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
- }
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);
98
85
  }
99
- else active.value = Math.min(active.value, length - 1);
100
86
  });
101
- function cleanup(): void {
102
- document.removeEventListener("pointerdown", outside, true);
103
- window.removeEventListener("resize", place);
104
- }
105
- onBeforeUnmount(cleanup);
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
106
  </script>
107
107
 
108
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; }
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; }
115
122
  @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); }
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; }
117
125
  }
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; }
126
+ @media (pointer: coarse) { .message-pin-tick { flex-basis: 44px; height: 44px; } }
122
127
  .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; }
128
+ .message-pin-marker svg { display: block; width: 13px; height: 13px; flex: 0 0 auto; }
124
129
  </style>
@@ -1,22 +1,134 @@
1
1
  <template>
2
- <span class="room-typing-indicator" role="status" aria-live="polite" aria-atomic="true">{{ label }}</span>
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>
3
23
  </template>
4
24
  <script setup lang="ts">
5
- defineProps<{ label: string }>();
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
+ }
6
48
  </script>
7
49
  <style scoped>
8
- .room-typing-indicator {
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 {
9
67
  position: absolute;
10
- top: 0;
11
- left: 18px;
12
- right: 18px;
13
- height: 12px;
68
+ width: 1px;
69
+ height: 1px;
14
70
  overflow: hidden;
71
+ clip-path: inset(50%);
15
72
  white-space: nowrap;
16
- text-overflow: ellipsis;
17
- pointer-events: none;
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;
18
82
  color: var(--text-tertiary, var(--muted));
19
- font-size: 11px;
20
- line-height: 12px;
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; }
21
133
  }
22
134
  </style>
@@ -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
+ }